Android端有声书字幕交互实现及优化全解析

前言

目前在七猫免费小说 App 端,音频内容主要有 TTS 和有声书两种类型,TTS 由于天然存在文字内容,可以满足用户边听边读的需求,而有声书目前则只能输出声音。为了丰富用户的有声书使用体验,所以我们考虑为有声书增加字幕功能。在交互形态上,字幕功能存在一些比较特别的点,本文记录下它们实现和优化的过程,在迭代工作中大家遇到的需求各不相同,这里我们屏蔽掉大部分业务细节,主要聚焦于解决问题思路的形成及对方案的优化过程,希望能给大家带来一点收获。

功能形态简述

有声书功能,本质上就是播放一段 mp3 格式的音频文件,一般情况下,mp3 文件是由个人或者团队根据台本朗读然后录制出来,而字幕其实就是被朗读的台本,大家平时看视频,视频中人物或者旁白在说话时,字幕就会适时地出现,这里自然就涉及到了音视频的音轨对齐,而在有声书的场景中,字幕不像视频只会出现当前的这一句台词,而是以列表形式全部展示,它的音轨对齐就体现在会高亮当前被读的一小句,并且自动滚动到中间位置。那么如何将文字形式的字幕与音频的播放位置联系起来呢,其实也相当简单粗暴,就是为字幕里的每一个小句都打上开始时间和结束时间的标签就可以了,这样在音频不断的播放过程中,就能定位到当前读到了哪一小段,进而在屏幕中做对应的样式显示即可。整体形态如下:

目前主流的字幕生成方案都是依靠 ASR 技术实现的,即 Automatic Speech Recognition 自动语音识别技术,ASR 技术与本文内容关联不大,所以不做展开,有兴趣的同学可以自行研究。

方案及实现

在了解了功能的基本原理之后,开始思考如何在 Android 端具体落地。从上面的交互形态分析可以看到,主要的交互可以大致分为滚动交互和高亮交互两种,字幕在客户端是按段排列展示的,一段可能有一行或者几行,并且只有出现从 A 段字幕切换到 B 段字幕时才会触发滚动交互,段内的字幕内容的切换则不会,即滚动交互是基于段维度的。而高亮区域则是在段内的一小部分,这里我们可以将每个区域定义为一个小句,小句只在一个段落中完整存在不会跨段,段落由一个或多个小句构成,高亮的交互是基于小句维度。基于以上分析,我们可以抽象出数据结构:段落 CaptionsParagraphEntry 和小句 CaptionsSentenceEntry ,因为段落由若干小句组成,所以段落中持有一个小句的列表,此外两者都存在一个根据开始时间在列表中查找当前字幕的行为,因为字幕时间都是顺序递增的,所以很容易想到使用二分查找,那么我们需要实现这两个类的 compareTo 方法,比较的逻辑自然就是对开始时刻的大小判断。

确定数据源结构之后,接下来就是实现数据最终在客户端的展示了。

功能开搞,先看友商「手动狗头」。

因为我们字幕功能的产品形态大体上跟某珠穆朗玛 app 比较相似,所以我们来简单研究一下竞品是如何实现的。这里推荐某节出品的 CodeLocator ,能够非常便捷地查看 app 当前 Activity 名、无需 root 就可以查看页面层级、View 类名等,在需要向友商学习的时候非常好用。

珠穆朗玛的页面层级如下:

可以看到整个字幕区域是一整个 View,并且没有其他子 View,那基本可以确定它的字幕文字都是直接使用 canvas 绘制出来的(魔改 TextView 也不是不可以,但是成本比较高,估计珠穆朗玛的同学不会这么做),那么基于直接使用 canvas 绘制文字来呈现字幕这个思路,我们来思考一下如何实现字幕的排列、高亮以及自动滚动。

首先,分析一下我们的需求,字幕是每一句结束后换行,即每一句为一个自然段,并且段落之间有独立的段间距,那么自然不是无脑把字幕字符串拼起来,然后调用一个 drawText() 就结束了,至少是需要逐段来绘制的,并且对 canvas 的 drawText() API 熟悉的同学应该知道,这个方法是不支持文字内容自动换行的,那么怎么在 canvas 上绘制能够自动换行的文字呢?这里就需要介绍一个在实现我们字幕功能过程中发挥重要作用的类:StaticLayout ,先看一下官方介绍:

StaticLayout is a Layout for text that will not be edited after it is laid out. Use DynamicLayout for text that may change.

This is used by widgets to control text layout. You should not need to use this class directly unless you are implementing your own widget or custom display object, or would be tempted to call Canvas.drawText() directly.

StaticLayout 是文本布局后不会被编辑的布局。 对可能更改的文本使用 DynamicLayout。

组件使用它来控制文本布局。 您不需要直接使用此类,除非您要实现自己的组件或自定义显示对象,或者想要直接调用 Canvas.drawText()。

官方介绍过于抽象,不过能看到它是能控制文本布局的,即我们需要的自动换行(实际上原生的 TextView 也是通过使用 StaticLayout 来实现自动换行的,有兴趣的可以自行翻阅源码),并且 StaticLayout 在创建之后就能够直接获取到文本行数、高度等等(而在一个基于 View 体系的对象中只能在测量流程执行完之后才能计算出来),这在后续的步骤中发挥了重要的作用,下文会说到。

ok,那么我们的字幕文本的绘制就不再是直接调用 canvas.drawText() 了,而是变成调用 staticLayout.draw(canvas),不过这里仅仅是一段文字的绘制,对于我们整体字幕的绘制来说,肯定是对段落列表进行遍历依次绘制的,那么 canvas 在完成一段的绘制之后,自然要执行 canvas.translate(float dx, float dy) 方法来往下移动一段距离,不然绘制出来的文字都重叠到一起了。这里移动的距离就是上一段文字内容的高度 + 段间距,这里就要再次感谢 StaticLayout ,我们得以直接获取上一段文本的高度,不然这个如果手动计算还是挺麻烦的,段间距可以作为一个 attr 属性由外部动态配置,那么到这里,我们就完成了一个可以按段落绘制、支持配置段间距的字幕控件了。

接下来则需要实现文字中的部分高亮,对于一段文字内容中部分文字显示不同的颜色的需求,Android 的同学应该都再了解不过了:“这个我熟,SpannableString 啊!” 哈哈不过这里的绘制可不是 TextView 噢,是我们自己调用 canvas 和 StaticLayout 绘制的,不过既然 TextView 绘制文字也用到了 StaticLayout 的话,StaticLayout 是不是本身也支持 SpannableString 呢?答案是肯定的(不会告诉你我是偷偷问了 ChatGPT),在 StaticLayout 的构造方法中 CharSequence source 参数直接传入我们构建好的 SpannableString 对象,颜色、字体大小、粗体等等就都可以配置啦。

最后需要解决的就是滚动定位到高亮部分的问题了,根据我们的需求,当高亮位置变化后,高亮部分需要滚动到居中位置。简单拆解一下需求:每一个高亮部分对应的是一个 CaptionsSentenceEntry 对象,那么我们就需要对于每一个 CaptionsSentenceEntry 对象设置其对应在字幕容器中的位置,当高亮位置变化后,找到对应的 CaptionsSentenceEntry 对象获取对应位置,再调用字幕容器的 scrollTo 方法就可以了。那么每个 CaptionsSentenceEntry 对象对应在字幕容器中的位置要怎么获取呢?没错,还是 StaticLayout,因为我们知道 StaticLayout 可以直接获取高度而没有测量、布局等前置条件,那么在我们拿到文本内容之后就已经完全可以算计出其每一段的高度,那么对于每个段落来说,累加上前面各段落的高度和段间距,就能算出它在字幕容器中的位置了,这里是段维度,而每段里的小句的高度则是当前段的位置+此小句所在行在此段中的高度(StaticLayout 同样能获取每行的高度),那么我们在 CaptionsSentenceEntry 类中增加 init 方法如下:

public class CaptionsSentenceEntry implements Comparable<CaptionsSentenceEntry> {

    // 仅用于计算坐标的文本(组成为:当前 Paragraph 前面的文本 + 此 Sentence 的第 0 个字符)
    private final String textForCalculate;
    // 此 Sentence 的 首行 在 段落 中的位置
    private int inTextAreaPosition;

    // 省略部分代码...

    /**
     * init 方法提前计算出此小句在段落中的位置
     */
    public void init(TextPaint paint, int width) {
        StaticLayout staticLayout = new StaticLayout(textForCalculate, paint, width, Layout.Alignment.ALIGN_NORMAL, 1f, 0, true);
        inTextAreaPosition = (staticLayout.getLineTop(staticLayout.getLineCount()) + staticLayout.getLineTop(staticLayout.getLineCount() - 1)) / 2;
    }

    // 省略部分代码...
}

根据需求,如果遇到小句跨行的场景,则定位其第一行到中间位置即可,这里的 textForCalculate 用了一点小心思,取的是本段落在此小句之前的所有内容+此小句的第一个字符,这样就完美解决了到此小句开头刚好换行导致的行数少计算了一行的问题。用此 textForCalculate 创建 StaticLayout,然后取 StaticLayout 的最后一行(即当前小句的第一行)的 Top 即得到此小句在此段落中的位置。

至此,我们已经完成了字幕功能的几个核心点,看一下最终的 TestCaptionsView:

public class TestCaptionsView extends View {
    private List<CaptionsParagraphEntry> mParagraphEntryList = new ArrayList<>();
    private TextPaint mCaptionsPaint = new TextPaint();
    ...
    private Scroller mScroller;
    private float mOffset;
    // 当前行
    private int mCurrentLine;
    // 当前断句
    private int mCurrentSentence;

    // 省略部分代码...
 
    public void onCaptionsLoaded(List<CaptionsParagraphEntry> entryList) {
        if (entryList != null && !entryList.isEmpty()) {
            mParagraphEntryList.addAll(entryList);
        }
 
        initEntryList();
        invalidate();
    }
 
    private void initEntryList() {
        if (getWidth() == 0) {
            return;
        }
        float offset = 0;
        for (CaptionsParagraphEntry entry : mParagraphEntryList) {
            entry.init(mCaptionsPaint, getWidth(), mTextGravity);
            offset += entry.getHeight() + paragraphGap;
            for (CaptionsSentenceEntry sentenceEntry : entry.getSentenceEntryList()) {
                sentenceEntry.init(captionsPaint, width);
            }
        }
        mOffset = getHeight() / 2;
    }
 
    public void updateTime(long time) {
        post(() -> {
            if (mParagraphEntryList.isEmpty()) {
                return;
            }
 
            int line = findShowParagraph(time);
            if (line != mCurrentLine) {
                mCurrentLine = line;
                mCurrentSentence = findShowSentence(time);
                invalidate();
                // 滚动至中间位置
                smoothScrollTo(mParagraphEntryList.get(line).getSentenceEntryList().get(mCurrentSentence).getInTextAreaPosition());
            } else {
                int sentence = findShowSentence(time);
                if (sentence != mCurrentSentence) {
                    mCurrentSentence = sentence;
                    invalidate();
                    // 滚动至中间位置
                    smoothScrollTo(mParagraphEntryList.get(line).getSentenceEntryList().get(mCurrentSentence).getInTextAreaPosition());
                }
            }
        });
    }
 
    /**
     * 二分法查找当前时间应该显示的段落
     */
    private int findShowParagraph(long time) {
        int left = 0;
        int right = mParagraphEntryList.size();
        while (left <= right) {
            int middle = (left + right) / 2;
            long middleTime = mParagraphEntryList.get(middle).getTime();
 
            if (time < middleTime) {
                right = middle - 1;
            } else {
                if (middle + 1 >= mParagraphEntryList.size() || time < mParagraphEntryList.get(middle + 1).getTime()) {
                    return middle;
                }
                left = middle + 1;
            }
        }
        return 0;
    }
 
    /**
     * 二分法查找当前时间应该显示的断句(最后一个 <= time 的行数)
     */
    private int findShowSentence(long time) {
        // 与 findShowParagraph 类似,省略部分代码...
    }
 
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        // 省略部分代码...
 
        float y = 0;
        for (int i = 0; i < mParagraphEntryList.size(); i++) {
            if (i > 0) {
                y += ((mParagraphEntryList.get(i - 1).getHeight() + mParagraphEntryList.get(i).getHeight()) >> 1);
            }
            if (i == mCurrentLine) {
                mCaptionsPaint.setTextSize(mCurrentTextSize);
                mCaptionsPaint.setColor(mNormalTextColor);
                if (mParagraphEntryList.get(i).getSentenceEntryList().size() > 0) {
                    CaptionsSentenceEntry sentenceEntry = mParagraphEntryList.get(i).getSentenceEntryList().get(mCurrentSentence);
                    Layout.Alignment alignment = Layout.Alignment.ALIGN_NORMAL;
 
                    // 实现当前断句的高亮绘制
                    SpannableString spannableString = new SpannableString(mParagraphEntryList.get(i).getText());
                    int startIndex = sentenceEntry.getStartIndex();
                    int endIndex = sentenceEntry.getEndIndex();
                    spannableString.setSpan(foregroundColorSpan, startIndex, endIndex, Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
 
                    StaticLayout staticLayout = new StaticLayout(spannableString, mCaptionsPaint, (int) getWidth(), alignment,
                            1.0f, 0.0f, false);
                    canvas.save();
                    canvas.translate(mCaptionsPadding, y - (staticLayout.getHeight() >> 1));
                    staticLayout.draw(canvas);
                    canvas.restore();
                }
            } else {
                mCaptionsPaint.setTextSize(mNormalTextSize);
                mCaptionsPaint.setColor(mNormalTextColor);
                canvas.save();
        		canvas.translate(mLrcPadding, y - (mLrcEntryList.get(i).getStaticLayout().getHeight() >> 1));
        		mLrcEntryList.get(i).getStaticLayout().draw(canvas);
        		canvas.restore();
            }
        }
    }
 
    // 省略部分滑动逻辑代码...
}

使用方式则是字幕列表加载成功后,调用 onCaptionsLoaded 方法设置字幕,当有声书的进度变化时,调用 updateTime 方法传入当前的播放时刻,内部会根据二分法(字幕对应时间都是递增,二分法查找效率最高)找到对应高亮位置然后刷新。

需求基本实现,奖励一杯咖啡~

咖啡喝完,我们来审视一下刚刚完成的代码,不难看出存在着几个明显问题:

1.数据完全耦合在 view 里了,并且看起来也比较难解耦,因为每次绘制都需要从 mParagraphEntryList 获取字幕内容、段高度等数据。

2.因为绘制高亮需要使用 SpannableString 的缘故,在 onDraw 存在创建对象的情况,影响应该大家都懂,不需要赘述了。

3.最重要的一点,每次绘制都需要把所有的字幕内容绘制一遍,这里带来的问题其实有两点:一、没有出现在屏幕中的内容其实根本不需要绘制;二、即使在屏幕中的内容,如果不涉及到高亮区域的变化,刷新也是没有必要的。

重点的事情压倒性投入,先着重看第三点:没有出现在屏幕中的内容不需要绘制,这个理论上在单 View 绘制的方案中也是可以实现的,在 onDraw 的时候根据 offset 计算出哪些段落不在屏幕区域中,直接跳过绘制即可,但是不涉及到高亮区域变化的部分不刷新,即局部刷新,单 View 绘制的方案应该是束手无策了 emm.. 那大概率是要考虑多子 View 组合、每段单独绘制&刷新的方案了,再结合看前几个问题,要数据解耦,要高效绘制,要独立刷新,是不是有一个词已经要呼之欲出了,哈哈没错,就是 RecyclerView,好像它能解决的问题完美符合我们的痛点,那么思考一下 RecyclerView 到底能不能承载我们的功能设计:1、按段落绘制、支持配置段间距;我们把每一段作为一个 RecyclerView 的 ViewHolder,直接用 TextView 绘制就可以了,至于行间距,则直接给 ViewHolder 里 itemView 设置上下 padding 就可以实现了。2、文字中的部分高亮;因为 ViewHolder 中是直接使用 TextView 绘制字幕,当然依旧可以使用 SpannableString 了。3、滚动定位到高亮部分;RecyclerView 本身就是为了滑动以显示更多内容而生的控件,我们在上面的方案中已经做到了计算出每个小句对应的位置,那么即便改成 RecyclerView 也自然是能轻松实现的(实际上实现起来没有像预想中那样轻松,后文会说到)。看起来调整成 RecyclerView 这个方案是完全ok的,并且我们之前做的大部分工作依旧能够发挥价值,那么改造工作走起。

关于 RecyclerView 相信大家都已经是非常了解了,所以只说一些重点内容:

1.因为我们改为 RecyclerView 实现的核心诉求之一是为了提高刷新效率,所以需要注意对于 Adapter 的刷新操作,Adapter 提供的 notifyDataSetChanged() 方法是一个万金油方法,当你想刷新时,调用它一定能达到你的预期,但它同时也是效率最低的方法,因为它是一个全量的刷新操作,这就与我们希望按需刷新的初心背道而驰了,所以除非是对全量刷新有明确预期的场景——即此处就是需要全量刷新,否则最好都不要调用此方法(调用这个方法的时候你是否注意到了 Google 给你的 warning 呢),可以使用 notifyItemChanged() 等用来做局部 item 刷新的方法,不过这样确实会多一些获取更新位置等等的代码,这可能也是大部分人明知 notifyDataSetChanged() 方法低效但是仍然使用它的原因,所以 Google 贴心地提供了 DiffUtil 类来为你简化这些代码,你只需要根据具体的业务逻辑实现一个 DiffUtil.Callback 来告诉 DiffUtil 判断 item 是否发生变化的标准,剩下的就是把你的数据扔给 DiffUtil,它会自动帮你完成高效的按需刷新。在字幕场景下,DiffUtil.Callback 的定义如下:

public class TestCaptionsDiffCallback extends DiffUtil.Callback {

    private List<CaptionsParagraphEntry> mOldList, mNewList;
    private int mCurrentParagraphIndex = Integer.MIN_VALUE, mOldParagraphIndex = Integer.MIN_VALUE, mCurrentSentenceIndex = Integer.MIN_VALUE, mOldSentenceIndex = Integer.MIN_VALUE;

    public void setOldList(List<CaptionsParagraphEntry> oldList) {
        mOldList = oldList;
    }

    public void setNewList(List<CaptionsParagraphEntry> newList) {
        this.mNewList = newList;
    }

    public void updateHighLightIndex(int paragraphIndex, int sentenceIndex) {
        mOldParagraphIndex = mCurrentParagraphIndex;
        mCurrentParagraphIndex = paragraphIndex;
        mOldSentenceIndex = mCurrentSentenceIndex;
        mCurrentSentenceIndex = sentenceIndex;
    }

    @Override
    public int getOldListSize() {
        return mOldList == null ? 0 : mOldList.size() + 1;
    }

    @Override
    public int getNewListSize() {
        return mNewList == null ? 0 : mNewList.size() + 1;
    }

    @Override
    public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
        if (mCurrentParagraphIndex == Integer.MIN_VALUE) {
            return false;
        }
        if (mOldList == null || mNewList == null) {
            return false;
        } else {
            return oldItemPosition == newItemPosition;
        }
    }

    @Override
    public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
        if (mOldParagraphIndex == Integer.MIN_VALUE) {
            return false;
        }
        if (oldItemPosition == newItemPosition) {
            if (mOldParagraphIndex != mCurrentParagraphIndex || mOldSentenceIndex != mCurrentSentenceIndex) {
                // +1 是因为 recyclerView 第 0 个 item 是 CopyRightDesc
                return newItemPosition != mOldParagraphIndex + 1 && newItemPosition != mCurrentParagraphIndex + 1;
            } else {
                return true;
            }
        }
        return false;
    }
}

其实在你了解 DiffUtil.Callback 是发挥什么作用之后就很容易理解你需要实现的这几个方法了,核心就是在调用刷新后 DiffUtil 需要知道各个位置的 item 是否是之前的 item ,如果是的话它的内容有没有发生变化,如果都没有变化就不需要刷新了,反之则需要刷新。这里有一个容易形成思维定势的点,就是 item 是否发生变化并不一定取决于其对应的列表数据有没有发生变化,比如此处我们字幕列表中的数据其实在设置一次后就不会改变了,item 变化与否取决于此处有没有涉及高亮位置的变化,这里的高亮位置相当于列表数据来说就属于“外部数据”,所以刷新时只需要更新 Callback 中记录的当前高亮位置,同时保存下老的高亮位置,Callback 执行刷新时判断新老位置相同的就是同一个 item ,如果刷新前后都不是高亮位置的话内容就不需要改变。使用 DiffUtil 刷新 RecyclerView 的代码如下:

int oldParagraphIndex = captionsAdapter.getCurrentParagraphIndex();
int paragraphIndex = captionsAdapter.findShowLine(time);
int sentenceIndex = captionsAdapter.findShowSentence(time, paragraphIndex);
// 先判断当前高亮位置有没有发生改变,改变才刷新 recyclerView
boolean isChanged = captionsAdapter.updateIndex(paragraphIndex, sentenceIndex);
if (isChanged) {
    captionsDiffCallback.setOldList(captionsAdapter.getData());
    captionsDiffCallback.updateHighLightIndex(paragraphIndex, sentenceIndex);
    DiffUtil.DiffResult result = DiffUtil.calculateDiff(captionsDiffCallback);
    result.dispatchUpdatesTo(captionsAdapter);
}

顺便提一句 DiffUtil.Callback 还有 getChangePayload() 方法可以重载,对应的是 Adapter 中带 List<Object> payloads 参数的 onBindViewHolder 方法,用来执行 item 内的局部刷新,不过我们这次没有涉及,就不做展开。

2.DiffUtil.Callback 刷新时会自动使用 RecyclerView 自带的动画效果,在快速滑动有声书进度条切换进度这种连续刷新位置的场景下,由于执行动画的原因,item 会有重影出现,由于字幕场景本身不需要动画,所以调用 recyclerView.setItemAnimator(null) 关闭掉 RecyclerView 的自带动画即可。

3.预期中使用 scrollTo 方法可以滑动到目标位置,但是调用之后发现没有效果,查看源码后发现 RecyclerView 的 scrollTo 方法是空实现:

    @Override
    public void scrollTo(int x, int y) {
        Log.w(TAG, "RecyclerView does not support scrolling to an absolute position. "
                + "Use scrollToPosition instead");
    }

并且建议我们使用 scrollToPosition(int position) 方法,其中 position 参数是 item 的位置,但是这个方法如果目标 item 在屏幕中的话不会有任何效果,不在屏幕中的话,会将目标 item 滚动到出现在屏幕中即停止,与我们想要滚动到居中位置的需求并不一致,不过 RecyclerView 的高扩展性同样为我们提供了解决方法,即 RecyclerView.SmoothScroller。从名字上就可以看出,是专门用来解决滚动需求的,内部有一个抽象方法 onTargetFound,在目标 item 滚动到出现在屏幕后回调,然后我们就可以接管接下来的滚动过程了,具体实现如下:

public class TestCaptionsLinearSmoothScroller extends LinearSmoothScroller {

    private OrientationHelper orientationHelper;

    // 省略部分代码...

    public void stopCurrent() {
        if (isRunning()) {
            stop();
        }
    }

    /**
     * 当目标 item 出现在屏幕中后会回调
     */
    @Override
    protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
        int distance = calculateDistance(targetView);
        int time = Math.min(calculateTimeForDeceleration(distance) * 30, 200);
        if (time > 0) {
            // 接管接下来的滚动过程
            action.update(0, distance, time, mDecelerateInterpolator);
        }
    }

    private int calculateDistance(View targetView) {
        // startProvider 提供高亮区域在 itemView 中的开始位置
        int highLightAreaStart = getOrientationHelper().getDecoratedStart(targetView) + startProvider.getHighLightAreaInItemStart();
        int containerCenter;
        if (getLayoutManager().getClipToPadding()) {
            containerCenter = getOrientationHelper().getStartAfterPadding() + getOrientationHelper().getTotalSpace() / 2;
        } else {
            containerCenter = getOrientationHelper().getEnd() / 2;
        }
        return highLightAreaStart - containerCenter;
    }
}

因为我们只需要实现垂直的线性滑动,所以直接继承 LinearSmoothScroller 即可,核心的逻辑就是在 onTargetFound 方法中执行继续滚动到居中位置的逻辑,calculateTimeForDeceleration 方法由 LinearSmoothScroller 提供,用来计算以标准滚动速度滑动目标距离需要的时间,对于已经在屏幕中显示的 item,由于滚动距离较短,以标准速度滚动的时间过短,看不出来滚动效果,所以这里取计算结果 * 30 与 200ms 中的最小值来作为执行滚动的时间,然后将滚动时间传入 action.update 方法即可执行接下来的滚动。另外提供一个 stopCurrent() 方法,用来在连续执行滚动的场景下停止当前的滚动,以提升效率。需要滚动高亮字幕到居中位置的代码如下:

captionsScroller.stopCurrent();
captionsScroller.setTargetPosition(captionsAdapter.getCurrentParagraphIndex());
captionsLayoutManager.startSmoothScroll(captionsScroller);

4.另外补充一条与 RecyclerView 无关的小优化:对于高亮段落及小句的查找,我们基于数据有序排列的特性使用了二分法,但是对于字幕的场景来说,除非是手动调整进度的情况(用户拖动进度条、快进/退 15s、跳过片头/尾等),其他情况下播放时刻总是线性增加的,所以我们没有必要每次都使用二分法从头开始找,而可以先使用当前位置进行判断(因为有可能高亮位置根本没有变化),如果没有命中,再使用当前位置的下一位置进行判断,如果依然没有命中,再使用二分法进行查找。这样,就只有在手动调整进度后的第一次查找才需要走到二分查找,其他情况都会执行到我们的优化策略。大家在遇到查找场景时,也可以使用类似的策略来做优化。

改造工作完毕,我们来简单看一下性能表现,以下是 mi6 设备(目前应该属于中低端设备)快速滑动字幕列表时的绘制渲染曲线,为了尽量排除其他因素影响,有声书处于暂停状态并关闭广告:

可以看到我们的字幕列表的渲染曲线比较平均,且渲染耗时基本都在 16ms 标准时间以内。

总结

至此,我们的字幕功能的开发过程就结束撒花了,虽然对于功能的开发告一段落,但是对于代码的优化还是会持续不断的进行,包括我在写下这篇文章的过程中,回看写过的代码时,都能发现不少可以优化的地方,所以定期 review 写过的代码也是一个值得养成的习惯,另外,对于 RecyclerView 原以为自己已经相当熟悉,但是这一次的使用过程中依然发现了不少之前没有触及到的点,同时再一次为 RecyclerView 各模块(布局、渲染、滚动等等)之间的解耦程度以及超强的扩展能力而赞叹,它的源码设计对于架构大型复杂系统时有很高的借鉴意义,可以说是常看常新。

ok,关于有声书字幕功能开发的记录就写到这里,希望能给大家带来一点小小的帮助,Peace~

展示评论