Android完美实现录音笔记功能

Android完美实现录音笔记功能

可能是神马笔记最长的一个开发版本了。

6月10日开始进行技术准备,6月26日才提交新版本测试。

前后用了17天的时间,最终的实现效果非常棒,可能是安卓平台最好用的录音笔记功能。

一、目标

实现神马笔记的录音笔记功能。

二、准备工作回顾

经过了10个开发过程,终于可以开始整合最终功能了。

序号 类别 开发过程 描述
1 录音编辑器 Android实现录音功能汇总 决定录音的实现方式
2 Android低仿iOS Messages录音波形效果 实现录音波形
3 Android高仿iOS Messages声音播放波形效果 实现播放波形
4 Android高仿iOS Messages录音操作按钮 实现录音按钮
5 Android使用PopupWindow高仿iOS Messages录音弹出界面 实现录音界面
6 Android完美实现录音编辑器 最终编辑器
7 音频播放器 Android高仿iOS圆环进度条 播放进度条
8 Android使用MediaPlayer播放音频 最终播放器
9 音频控制器 Android使用AudioManager切换到听筒模式 控制音频输出
10 便捷操作 Android模仿iOS Messages拿起手机靠近耳朵自动录音 实现快速录音

三、开发过程

1. 增加录音功能

截图 说明
右下角的发送按钮,调整为录音按钮。
长按录音按钮启动录音功能。
点击录音按钮,显示操作提示。
长按录音按钮,启动录音功能。

2. 请求录音权限

录音功能是敏感功能,因此使用之前必须请求用户授权。使用RxPermissions可以很容易实现这个功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void requestTape() {
this.tapeHelper.stop();
this.tapeHelper.resetSpeaker();

PermissionHelper helper = new PermissionHelper(getActivity());
helper.setPermission(Manifest.permission.RECORD_AUDIO);

final boolean isGranted = helper.isGranted();
PermissionHelper.OnPermissionListener consumer = (h) -> {

bottomBar.chatBar.setVisibility(View.INVISIBLE);

popupTape.show(isGranted);
};

String requestMsg = "请允许录制音频,以发送语音。";
String deniedMsg = "请在「权限管理」,设置允许「麦克风」,以发送语音。";

helper.setRequestMessage(requestMsg);
helper.setDeniedMessage(deniedMsg);
helper.setOnPermissionListener(consumer);
helper.request();
}

3. 整合录音编辑器

用户授权通过后,即可开始进行录音。

之前的开发将录音编辑器独立为单独的功能模块,直接使用即可。

需要注意的是dispatchTouchEvent的处理。

需要手动处理dispatchTouchEvent才能实现弹出后,用户可以继续操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
FragmentManager fm = getSupportFragmentManager();
Fragment f = fm.findFragmentByTag(CONTENT);
if (f != null && f instanceof OnDispatchKeyEventListener) {
OnDispatchTouchEventListener listener = (OnDispatchTouchEventListener)f;
boolean result = listener.dispatchTouchEvent(ev);
if (result) {
return true;
}
}

return super.dispatchTouchEvent(ev);
}
1
2
3
4
5
6
7
8
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
if (popupTape != null && popupTape.dispatchTouchEvent(event)) {
return true;
}

return false;
}

4. 定义录音数据结构

录音数据结构包含3个层次。

序号 类定义 说明
1 TapeEntry 存储数据
2 TapeEntity 逻辑数据
3 TapeItem 交互数据

以及与TapeItem对应的ViewHolder类。

  • TapeItem
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
public class TapeItem extends ChatItem<TapeEntity> {

Waveform waveform;

public TapeItem(ChatProvider parent, TapeEntity entity) {
super(parent, entity, TYPE_TAPE);
}

@Override
protected int getTypeStyle() {
return STYLE_SINGLE;
}

public int getDuration() {
return entity.getDuration();
}

public File getFile() {
return entity.getFile();
}

public Waveform getWaveform() {
if (waveform == null) {
this.waveform = Waveform.read(entity.getWaveform());
}

return waveform;
}

public void setWaveform(Waveform waveform) {
this.waveform = waveform;
}

public TapeHelper getTapeHelper() {
return parent.getTapeHelper();
}

public AudioPlayer getAudioPlayer() {
return getTapeHelper().getAudioPlayer();
}

public boolean isActive() {
TapeHelper helper = this.getTapeHelper();
boolean result = (helper.getActiveItem() == this);

return result;
}

public void activate() {
TapeHelper helper = this.getTapeHelper();
helper.setActiveItem(this);
}

public boolean isSpeakerOn() {
TapeHelper helper = this.getTapeHelper();
return helper.isSpeakerOn();
}

public void setSpeakerOn(boolean on) {
TapeHelper helper = this.getTapeHelper();
helper.setSpeakerOn(on);
}

public boolean isRunning() {
return getAudioPlayer().isRunning();
}

public boolean isPlaying() {
return getAudioPlayer().isPlaying();
}

public int getCurrentPosition() {
AudioPlayer player = this.getAudioPlayer();
return player.getCurrentPosition();
}

public void start() {
AudioPlayer player = this.getAudioPlayer();
player.setTarget(this.getFile());
player.start();

getTapeHelper().setWakeLock(true);
getTapeHelper().setScreenOn(true);
}

public void pause() {
AudioPlayer player = this.getAudioPlayer();
player.pause();

getTapeHelper().setWakeLock(false);
getTapeHelper().setScreenOn(false);
}

public void resume() {
AudioPlayer player = this.getAudioPlayer();
player.resume();

getTapeHelper().setWakeLock(true);
getTapeHelper().setScreenOn(true);
}
}
  • ViewHolder
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
public abstract class TapeViewHolder extends ChatViewHolder<TapeItem> {

View speakerLayout;
TextView speakerView;
ImageView speakerIcon;

View tapePlayLayout;
RingView progressView;
ImageView actionView;
TapePlayView tapePlayView;
Chronotimer chronometer;

int paddingLeft;
int paddingRight;
int paddingTop;
int paddingBottom;

@Keep
public TapeViewHolder(View itemView) {
super(itemView);
}

@Override
public void onViewCreated(@NonNull View view) {
super.onViewCreated(view);

{
viewStub.setLayoutResource(R.layout.layout_tape_chat_item);
View stub = viewStub.inflate();

this.speakerLayout = stub.findViewById(R.id.speaker_layout);
this.speakerView = stub.findViewById(R.id.tv_speaker);
this.speakerIcon = stub.findViewById(R.id.iv_speaker);

this.tapePlayLayout = stub.findViewById(R.id.tape_play_layout);
this.progressView = stub.findViewById(R.id.ring_view);
this.actionView = stub.findViewById(R.id.iv_action);
this.tapePlayView = stub.findViewById(R.id.tape_play_view);
this.chronometer = stub.findViewById(R.id.chronometer);

this.speakerLayout.setOnClickListener(this::onSpeakerClick);
this.tapePlayLayout.setOnClickListener(this::onPlayClick);
}

{
Resources resources = getContext().getResources();
this.paddingLeft = resources.getDimensionPixelSize(R.dimen.chatTapePaddingLeft);
this.paddingRight = resources.getDimensionPixelSize(R.dimen.chatTapePaddingRight);
this.paddingTop = resources.getDimensionPixelSize(R.dimen.chatTapePaddingTop);
this.paddingBottom = resources.getDimensionPixelSize(R.dimen.chatTapePaddingBottom);
}
}

@Override
public void onBind(TapeItem item, int position) {

boolean isActive = item.isActive();

{
speakerLayout.setVisibility(isActive? View.VISIBLE : View.INVISIBLE);

boolean enable = item.isSpeakerOn();
speakerIcon.setEnabled(enable);
speakerView.setEnabled(enable);

speakerView.setVisibility(View.INVISIBLE);
speakerView.animate().cancel();
}

{
tapePlayView.setWaveform(item.getWaveform());
tapePlayView.getLayoutParams().width = getWidth(item);
tapePlayView.requestLayout();
}

{
tapePlayLayout.setBackgroundResource(getBubble(item));
tapePlayLayout.setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom);
}

if (!isActive) {
{
progressView.stop();
progressView.setDuration(item.getDuration());
progressView.setProgress(0);

actionView.setImageResource(R.drawable.ic_record_play);
}

{
tapePlayView.setAudioPlayer(null);
}

{
chronometer.stop();
chronometer.setBase(item.getDuration());
}

} else {
boolean isRunning = item.isRunning();
boolean isPlaying = item.isPlaying();

{
progressView.stop();
progressView.setDuration(item.getDuration());
progressView.setProgress(isRunning? item.getCurrentPosition(): 0);

if (isRunning && isPlaying) {
progressView.start();
}

if (isRunning && isPlaying) {
actionView.setImageResource(R.drawable.ic_record_pause);
} else {
actionView.setImageResource(R.drawable.ic_record_play);
}
}

{
tapePlayView.setAudioPlayer(item.getAudioPlayer());
}

{
chronometer.stop();
chronometer.setBase(isRunning? item.getCurrentPosition(): item.getDuration());

if (isRunning && isPlaying) {
chronometer.start();
}
}
}

}

void onSpeakerClick(View view) {
TapeItem item = this.getItem();
if (item == null) {
return;
}

boolean value = item.isSpeakerOn();
value = !value;
item.setSpeakerOn(value);

String text = value? "打开": "关闭";
speakerView.setText(text);

speakerIcon.setEnabled(value);
speakerView.setEnabled(value);

speakerView.setVisibility(View.VISIBLE);
speakerView.animate().cancel();

long duration = speakerView.animate().getDuration();
speakerView.setAlpha(1);
speakerView.animate().setStartDelay(2 * duration).alpha(0.f).start();
}

void onPlayClick(View view) {
TapeItem item = this.getItem();
if (item == null) {
return;
}

if (!item.isActive()) {
item.start();
item.activate();
} else {
if (!item.isRunning()) {
this.start(item);
} else {
if (item.isPlaying()) {
this.pause(item);
} else {
this.resume(item);
}
}
}
}

void start(TapeItem item) {
item.start();

{
progressView.setDuration(item.getDuration());
progressView.setProgress(item.getCurrentPosition());
progressView.start();

actionView.setImageResource(R.drawable.ic_record_pause);
}

{
tapePlayView.setAudioPlayer(item.getAudioPlayer());
}

{
chronometer.setBase(item.getCurrentPosition());
chronometer.start();
}
}

void pause(TapeItem item) {
item.pause();

{
progressView.stop();
progressView.setDuration(item.getDuration());
progressView.setProgress(item.getCurrentPosition());

actionView.setImageResource(R.drawable.ic_record_play);
}

{
tapePlayView.setAudioPlayer(item.getAudioPlayer());
}

{
chronometer.stop();
chronometer.setBase(item.getCurrentPosition());
}
}

void resume(TapeItem item) {
item.resume();

{
progressView.setDuration(item.getDuration());
progressView.setProgress(item.getCurrentPosition());
progressView.start();

actionView.setImageResource(R.drawable.ic_record_pause);
}

{
tapePlayView.setAudioPlayer(item.getAudioPlayer());
}

{
chronometer.setBase(item.getCurrentPosition());
chronometer.start();
}
}

int getWidth(TapeItem item) {
int width = getContext().getResources().getDisplayMetrics().widthPixels;
int min = (int)(width * 0.04f);
int max = (int)(width * 0.40f);

int minDuration = 1 * 1000;
int maxDuration = 36 * 1000;

int duration = item.getDuration();

if (duration <= minDuration) {
width = min;
} else if (duration >= maxDuration) {
width = max;
} else {

float scale = 1.f * (duration - minDuration) / (maxDuration - minDuration);
width = (int)(scale * (max - min) + min);
}

return width;
}
}

5. 实现录音助手类

从界面上增加录音功能,到请求用户授权,再整合录音编辑器实现录音功能。

最后定义录音数据结构,将录音添加到笔记中。

因为RecyclerView的复用机制,不能将播放器的生命周期绑定到控件的生命周期。

比如说,用户滑动时,控件已经被复用,当播放不能停。

因此,实现录音助手类——TapeHelper来解决这个问题。

处理解决生命周期的问题,TapeHelper同时解决音频相关的问题。

序号 功能 描述
1 生命周期 Activity切换到后台时,停止播放。
2 扬声器 设置扬声器开关,注意扬声器根据插入耳机或者连接蓝牙耳机进行变化。
3 屏幕长亮 播放时,启动距离感应器以实现靠近熄灭屏幕功能。
4 播放管理 笔记中有多个录音时,一次只能播放一个录音,需要对其进行管理。
  • TapeHelper
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
public class TapeHelper implements LifecycleObserver {

AudioPlayer audioPlayer;

int previousMode;
boolean isPreviousSpeakerphoneOn;
AudioHelper audioHelper;

PowerManager.WakeLock wakeLock;

ChatItem activeItem;
RecyclerView recyclerView;

FragmentActivity context;

public TapeHelper(FragmentActivity context, RecyclerView recyclerView) {
this.context = context;
this.recyclerView = recyclerView;

this.audioPlayer = new AudioPlayer();
audioPlayer.setOnCompletionListener(this::onCompletion);

this.audioHelper = new AudioHelper(context);
this.previousMode = audioHelper.getMode();
this.isPreviousSpeakerphoneOn = audioHelper.isSpeakerphoneOn();

context.getLifecycle().addObserver(this);
}

public AudioPlayer getAudioPlayer() {
return this.audioPlayer;
}

public boolean isSpeakerOn() {
return PreferenceEntity.obtain().isSpeakerOn();
}

public void setSpeakerOn(boolean on) {
PreferenceEntity.obtain().setSpeakerOn(on);
PreferenceEntity.obtain().save();

audioHelper.setSpeakerOn(on);
}

public void setActiveItem(ChatItem next) {
if (next == activeItem) {
return;
}

ChatItem previous = activeItem;
if (previous != null) {
int position = previous.getParent().indexOf(previous);
recyclerView.getAdapter().notifyItemChanged(position);
}

if (next != null) {
int position = next.getParent().indexOf(next);
recyclerView.getAdapter().notifyItemChanged(position);
}

this.activeItem = next;
}

public ChatItem getActiveItem() {
return this.activeItem;
}

public void stop() {
this.setWakeLock(false);
this.setScreenOn(false);

if (!audioPlayer.isRunning()) {
return;
}

audioPlayer.pause();
audioPlayer.stop();
onCompletion(audioPlayer);
}

public void resetSpeaker() {
audioHelper.setMode(this.previousMode);
audioHelper.setSpeakerphoneOn(this.isPreviousSpeakerphoneOn);
}

public void setWakeLock(boolean value) {
if (value) {

if (wakeLock == null) {
String TAG = context.getPackageName() + ":tape";
PowerManager pm = (PowerManager)(context.getSystemService(Context.POWER_SERVICE));
PowerManager.WakeLock mWakeLock = pm.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, TAG);
mWakeLock.acquire();

this.wakeLock = mWakeLock;
}
} else {
if (wakeLock != null) {
wakeLock.release();
wakeLock = null;
}
}
}

public void setScreenOn(boolean on) {
if (on) {
context.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
} else {
context.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
}

void onCompletion(AudioPlayer player) {
this.setWakeLock(false);
this.setScreenOn(false);

if (this.activeItem == null) {
return;
}

ChatItem next = activeItem;
if (next != null) {
int position = next.getParent().indexOf(next);
recyclerView.getAdapter().notifyItemChanged(position);
}
}

@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
void onResume() {
audioHelper.setSpeakerOn(PreferenceEntity.obtain().isSpeakerOn());
}

@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
void onPause() {
this.stop();

this.resetSpeaker();
}

}

四、开发过程回顾

前期开发已经解决了所有的技术问题。

这次开发主要围绕功能展开,最复杂的功能集中在TapeItemTapeHelper的交互上。

五、发现的问题

测试过程中发现1个小问题和1个大问题。

  • 小问题

时间越界,显示为0秒的录音,播放时出现1秒,然后有显示为0秒。

  • 大问题

刘海屏如红米6Pro,录音位置会出现偏移,需要检查具体原因。

六、遗留问题

很遗憾不能在这个版本实现靠近录音功能,未来的版本将实现之。

七、接下来

处理问题,准备发布新版本。

八、Finally

如是我闻。 一时佛在舍卫国。祗树给孤独园。

与大比丘众。千二百五十人俱。