可能是神马笔记最长的一个开发版本了。
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拿起手机靠近耳朵自动录音 | 实现快速录音 |
截图 | 说明 |
---|---|
右下角的发送按钮,调整为录音按钮。 长按录音按钮启动录音功能。 |
|
点击录音按钮,显示操作提示。 | |
长按录音按钮,启动录音功能。 |
录音功能是敏感功能,因此使用之前必须请求用户授权。使用RxPermissions
可以很容易实现这个功能。
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();
}
用户授权通过后,即可开始进行录音。
之前的开发将录音编辑器独立为单独的功能模块,直接使用即可。
需要注意的是dispatchTouchEvent
的处理。
需要手动处理dispatchTouchEvent
才能实现弹出后,用户可以继续操作。
@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);
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
if (popupTape != null && popupTape.dispatchTouchEvent(event)) {
return true;
}
return false;
}
录音数据结构包含3个层次。
序号 | 类定义 | 说明 |
---|---|---|
1 | TapeEntry | 存储数据 |
2 | TapeEntity | 逻辑数据 |
3 | TapeItem | 交互数据 |
以及与TapeItem
对应的ViewHolder
类。
TapeItem
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
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;
}
}
从界面上增加录音功能,到请求用户授权,再整合录音编辑器实现录音功能。
最后定义录音数据结构,将录音添加到笔记中。
因为RecyclerView
的复用机制,不能将播放器的生命周期绑定到控件的生命周期。
比如说,用户滑动时,控件已经被复用,当播放不能停。
因此,实现录音助手类——TapeHelper
来解决这个问题。
处理解决生命周期的问题,TapeHelper
同时解决音频相关的问题。
序号 | 功能 | 描述 |
---|---|---|
1 | 生命周期 | Activity切换到后台时,停止播放。 |
2 | 扬声器 | 设置扬声器开关,注意扬声器根据插入耳机或者连接蓝牙耳机进行变化。 |
3 | 屏幕长亮 | 播放时,启动距离感应器以实现靠近熄灭屏幕功能。 |
4 | 播放管理 | 笔记中有多个录音时,一次只能播放一个录音,需要对其进行管理。 |
TapeHelper
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();
}
}
前期开发已经解决了所有的技术问题。
这次开发主要围绕功能展开,最复杂的功能集中在TapeItem
和TapeHelper
的交互上。
测试过程中发现1个小问题和1个大问题。
时间越界,显示为0秒的录音,播放时出现1秒,然后有显示为0秒。
刘海屏如红米6Pro,录音位置会出现偏移,需要检查具体原因。
很遗憾不能在这个版本实现靠近录音功能,未来的版本将实现之。
处理问题,准备发布新版本。
如是我闻。
一时佛在舍卫国。祗树给孤独园。
与大比丘众。千二百五十人俱。