Android完美实现录音笔记功能

Android完美实现录音笔记功能

    • 一、目标
    • 二、准备工作回顾
    • 三、开发过程
      • 1. 增加录音功能
      • 2. 请求录音权限
      • 3. 整合录音编辑器
      • 4. 定义录音数据结构
      • 5. 实现录音助手类
    • 四、开发过程回顾
    • 五、发现的问题
    • 六、遗留问题
    • 七、接下来
    • 八、Finally

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

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

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

一、目标

实现神马笔记的录音笔记功能。
Android完美实现录音笔记功能_第1张图片

二、准备工作回顾

经过了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. 增加录音功能

截图 说明
Android完美实现录音笔记功能_第2张图片 右下角的发送按钮,调整为录音按钮。
长按录音按钮启动录音功能。
Android完美实现录音笔记功能_第3张图片 点击录音按钮,显示操作提示。
Android完美实现录音笔记功能_第4张图片 长按录音按钮,启动录音功能。

2. 请求录音权限

录音功能是敏感功能,因此使用之前必须请求用户授权。使用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();
}

3. 整合录音编辑器

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

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

需要注意的是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;
}

4. 定义录音数据结构

录音数据结构包含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;
    }
}

5. 实现录音助手类

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

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

因为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();
    }

}

四、开发过程回顾

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

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

五、发现的问题

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

  • 小问题

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

  • 大问题

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

六、遗留问题

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

七、接下来

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

八、Finally

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

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

你可能感兴趣的:(神马笔记)