Android完美实现录音编辑器

Android完美实现录音编辑器

一、目标

实现录音编辑器,为神马笔记增加录音功能做准备。

二、准备工作

序号 准备工作 描述
1 Android实现录音功能汇总 全面了解Android实现录音功能的各种方式,并且比较方式的优劣,最终选择MediaRecorder来实现录音。
2 Android低仿iOS Messages录音波形效果 使用MediaRecorder实现录音,并封装成TapeRecordView方便调用。
3 Android高仿iOS Messages声音播放波形效果 使用MediaPlayer播放录音,并封装成TapePlayView方便调用。
4 Android高仿iOS Messages录音操作按钮 实现ActionLayout作为滑动按钮。
5 Android使用PopupWindow高仿iOS Messages录音弹出界面 选择录音编辑器的容器,比较了Activity、Dialog、PopupWindow、FrameLayout,并选择PopupWindow作为容器。

通过5个阶段的准备工作,解决了录音相关的所有技术问题。

最后一步便是把所有功能组合到一起。

三、功能分析

1. 界面组成

整个录音编辑器界面分为2个部分。

  1. 波形
  2. 操作
界面组成 控件组成
波形 关闭按钮
录音波形、播放波形
时间显示
操作 发送按钮
停止录音、播放录音,暂停播放

2. 事件处理

事件 触发条件 处理方式
布局变化 通常情况下,布局不会发生变化。
长按电源键或者来电时,会隐藏软键盘,从而引起布局变化。
调整编辑器位置。
切换到后台 用户按下多任务键或者Home键,或者来电时,将应用切换到后台。 停止录音、停止播放
截图 描述
长按电源键,将会隐藏软键盘。

四、实现过程

序号 过程
1 定义PopupTape作为编辑器类,并实现show方法以显示界面
2 实现录音及停止功能
3 实现播放及暂停功能
4 显示录音及播放时间进度
5 实现关闭功能,通过关闭按钮以及用户按下返回键
6 处理dispatchTouchEvent事件,实现弹出界面后可以继续操作。
7 处理onGlobalLayout事件,对编辑器重新布局。
8 处理onStop事件,切换到后台时自动停止录音及播放。
9 增加对外事件回调接口BiConsumer<PopupTape, Integer>,用于告知用户操作结果。
10 增加属性访问接口,以插叙PopupTape属性。

五、一些技术问题

1. MediaPlayer

MediaPlayer播放过程中,调用getCurrentPosition方法获取当前播放进度。

出现时间回退的情况,出现在250毫秒~300毫秒之间。

1
// ..., 256, 257, 258, 258, 224, 225, 226, 227, ...

导致了绘制波形时,出现闪烁的情况。

1
2
3
4
int current = mMediaPlayer.getCurrentPosition();
this.currentPosition = (current > this.currentPosition)? current: this.currentPosition;

current = this.currentPosition;

增加一个变量用于跟踪进度,防止回退。

2. Chronometer

使用Chronometer显示播放进度时,出现时间跳动的情况。

Chronometer虽然1秒回调一次,但并不是严格的1000毫秒,因此会出现累加的误差,最终导致时间跳动。

自定义Chronotimer,采用每帧刷新的方式保证时间的准确性。

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
public class Chronotimer extends androidx.appcompat.widget.AppCompatTextView {

boolean isRunning = false;

long base = 0; // base time in milliseconds

long start; // start time in milliseconds
long end; // end time in milliseconds

PreDrawListener preDrawListener;
private boolean mPreDrawRegistered;

public Chronotimer(Context context) {
this(context, null);
}

public Chronotimer(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, android.R.attr.textViewStyle);
}

public Chronotimer(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);

this.preDrawListener = new PreDrawListener();
}

public void start() {
this.isRunning = true;

this.start = System.currentTimeMillis();
this.end = start;

this.registerForPreDraw();
this.invalidate();
}

public void stop() {
this.isRunning = false;

this.end = System.currentTimeMillis();

this.unregisterForPreDraw(); // must unregister OnPreDrawListener, or else draw all the time.
this.invalidate();
}

public void setBase(long base) {
this.base = base;
}

@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();

if (isRunning) {
registerForPreDraw();
}
}

@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();

this.unregisterForPreDraw();
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);

if (isRunning) {
this.invalidate();
}

// Log.w("AA", "Chronotimer onDraw = " + isRunning);
}

public CharSequence format(long milliseconds) {
long elapse = milliseconds;
elapse /= 1000; // seconds

int hours = (int)(elapse / 3600);
int seconds = (int)(elapse % 3600);
int minute = (seconds / 60);
seconds = seconds % 60;

String text;
if (hours > 0) {
text = String.format("%d:%02d:%02d", hours, minute, seconds);
} else {
text = String.format("%d:%02d", minute, seconds);
}

return text;
}

private void registerForPreDraw() {
if (!mPreDrawRegistered) {

getViewTreeObserver().addOnPreDrawListener(preDrawListener);

mPreDrawRegistered = true;
}
}

private void unregisterForPreDraw() {
getViewTreeObserver().removeOnPreDrawListener(preDrawListener);

mPreDrawRegistered = false;
}

/**
*
*/
private class PreDrawListener implements ViewTreeObserver.OnPreDrawListener {

@Override
public boolean onPreDraw() {
if (isRunning) {
end = System.currentTimeMillis();
}

long duration = (end - start);
duration += base;

CharSequence text = format(duration);
setText(text);

return true;
}
}
}

3. 保持屏幕长亮

为防止锁屏自动停止录音,因此保证录音过程中屏幕长亮。

4. 关闭编辑器

与iOS Messages点击操作按钮外部区域管理编辑器不同。

必须点击关闭按钮才能关闭编辑器。

iOS Messages是聊天工具,录音内容通常较短。

神马笔记是笔记工具,用户可能进行长时间录音。

为防止用户误操作关闭编辑器,因此采用关闭按钮的方式。

5. 录音品质

使用CD音质参数,一秒钟大约为23.6KB。

参数 数值
OutputFormat MediaRecorder.OutputFormat.MPEG_4
AudioEncoder MediaRecorder.AudioEncoder.AAC
AudioChannels 1
AudioSamplingRate 44100
AudioEncodingBitRate 192000
时间 大小
1秒钟 约23.6KB
1分钟 约1.4MB
6分钟 约8.3MB
1个小时 约83MB

六、开发过程回顾

集合了前面的所有功能,终于完成录音编辑器。

高仿了iOS Messages的录音功能:)

效果还算满意。

七、接下来

完成录音显示,及RecyclerView中多个录音的播放功能。

神马笔记实现录音笔记元素做准备。

八、Finally

佛说是经已。 长老须菩提。 及诸比丘。比丘尼。 优婆塞。优婆夷。 一切世间天人阿修罗。 闻佛所说。皆大欢喜。信受奉行。