ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OpenHarmony上RN原生缓冲进度条组件封装与性能优化

OpenHarmony上RN原生缓冲进度条组件封装与性能优化

最近在做播放器模块,产品给了一个“视频缓冲进度条”的原型稿——底层是一条半透明的灰色条,代表已经缓冲到本地/播放器缓存的进度;上层是一条彩色条,代表当前实际播放到的位置;两者之间还要能拖拽跳转。刚开始我特别乐观,觉得RN生态里进度条组件一抓一大把,随便找个用就行。等我把社区主流的进度条库翻了一遍才发现,这些库要么只支持Android/iOS,要么底层是WebView套壳,放到OpenHarmony上根本不认。后来我只能从零封装一个原生进度条组件,顺便把缓冲进度显示这个很多人都会忽略的细节彻底做透了。这篇文章就把这段经验完整写下来,包括组件设计思路、JS和原生两侧的桥接实现、高频更新场景下的性能优化,以及我踩过的几个让人抓狂的坑。如果你也在用RN做跨端应用,目标平台里恰好有OpenHarmony,那么这篇内容能帮你少走不少弯路。

1. 项目背景与方案选型:为什么这个需求不简单

1.1 需求场景和RN组件选型的现实困境

先说一下业务现场。我负责的是一个播放器模块,需要支持在线视频播放、音频加载和直播回看。在线播放最典型的状态就是“当前播放位置”和“已缓冲位置”两个进度,而系统自带的进度条通常只表达“当前进度值”这一维度。所以产品给出的交互是:

  • 轨道层:最底部一条透明底,表示“什么都没有加载”的默认背景。
  • 缓冲层:根据缓冲进度显示一条淡灰色半透明条,表示“已经拉下来但还没播放”的数据范围。
  • 播放层:根据播放进度显示一条高亮色条,表示“当前播放到哪”。
  • 滑块:高亮条前端有一个可拖拽的点,拖动时显示预览时间。

这个需求在原生Android里很简单,一个ProgressBar加secondaryProgress就能搞定。但在RN生态里几乎没有直接暴露“辅助进度”的跨端组件。我试着找开源库,能搜到的进度条库基本都在做样式美化,支持的是value、max、onSlidingComplete这类通用字段,缓冲进度这个属性要么没实现,要么只是JS层模拟的假样式,并不能反映播放器真实状态。

更麻烦的是OpenHarmony的系统控件里虽然也有Progress组件,但它的语义比较基础,一般表达“任务完成百分比”这种单值进度,并没有把“缓冲进度”作为一等公民。也就是说,即便我直接用ArkTS写一个原生模块,也没法靠系统控件开个开关就支持双进度。真正可行的办法只有一个:自己动手,把缓冲层和播放层渲染到一起。

1.2 三条路线对比:纯JS模拟、原生封装、混合实现

面对“RN开发OpenHarmony应用”这个场景,我先把可选方案理了一遍,最后总结成三条路线:

方案实现成本性能表现交互手感开发可控性
纯JS用View模拟双层进度条最低,两三小时就能出效果差。频繁setState会造成整棵树re-render,拖动时尤其卡一般。需要自己写手势,容易和播放器手势冲突高,完全在JS侧控制
RN封装原生组件较高,需要写桥接层好。进度更新直接走UI线程,不经过JS好。可以拿到原生触摸事件,精准计算高,但需要理解RN原生组件机制
原生ArkTS页面+RN混合最高,需要把播放页改成混合工程最好最好低,之后其他业务要复用时很麻烦

我最后选了“RN封装原生组件”这条路线。原因很明确:业务主体是RN代码,播放器页面也要尽量复用现有的跨端组件。如果为了一个进度条把整个播放页拆出去做原生,那跨端的意义就打折了。反过来,如果纯用JS模拟,虽然在简单演示里看起来像样,但真实播放器里缓冲进度每秒会更新几十次,播放进度也会随着拖动实时变化,JS线程根本扛不住这种频率,更别说还要处理列表页里多个视频卡片同时滚动时的大量进度条。所以,优先级最高的其实是性能和可复用性,而非开发速度。

1.3 理解RN到OpenHarmony的组件通信链路

在正式写代码之前,得先搞清楚一条链路:RN里的组件是怎么变成OpenHarmony原生组件的。这么说吧,React Native的组件并不是真正自己画在屏幕上的,它只是把“你有一个进度条,值是多少,什么颜色”这些描述信息,通过Native组件机制交给原生层去创建和更新。

旧的架构会经过UIManager,新架构则走Fabric。底层本质上还是同一件事:JS侧描述一个组件树,原生侧根据描述创建对应的视图,属性更新时原生侧更新视图。OpenHarmony上跑RN,同样需要治这么一条管道。在写自定义ProgressBar的时候,我要做一套“注册”动作:告诉原生系统,“有一个组件叫BufferedProgressBar,JS侧可能会用到它,它的属性有progress、bufferProgress等”,然后原生侧准备好一个组件实现类,负责接收这些属性并渲染。

这块内容容易劝退很多初学者,因为和平时写普通React组件感觉完全不一样。但只要你理解“JS只管描述,原生管渲染”这个核心思想,后面看代码就会顺很多。我在工程里也做了另一个很小的验证组件,确认整个注册链路没问题后才开始正式封装。千万别一上来就写复杂功能,否则出了问题你根本分不清是属性没传对、注册没成功,还是渲染逻辑写错了。

2. 环境搭建与工程集成实操

2.1 搭建OpenHarmony上的RN运行环境

如果你已经有一个可以正常运行的OpenHarmony应用工程,那环境这块就省事很多。我这边是把一个已有的RN跨端仓库,集成到OpenHarmony应用壳工程里。这里面的“壳工程”指的是一个能安装到OpenHarmony设备上的原生应用,它负责加载RN的bundle,并运行JS引擎。

做这件事之前,先确认一下版本匹配关系。RN本身对OpenHarmony并没有官方支持,能跑起来靠的是社区维护的适配层,所以版本敏感度很高。我的建议是:

  • 先看你的适配层支持哪些RN版本,再决定升还是不升。
  • 不要随意用最新版RN,很多适配层跟进速度没那么快。
  • 保持package.json里的react、react-native版本和适配层要求一致。

我个人用的是一套已经打了针对OpenHarmony补丁的RN版本,RN主版本选的是0.72以上。这个版本在Fabric架构和事件交互上都比较成熟,适配层对其也做过多轮验证。如果你是从旧项目迁移过来,尽量保持架构一致,不要一个工程里混用旧接口和新接口。

2.2 创建RN入口并加载bundle

RN工程和OpenHarmony壳工程之间的核心衔接点,就是bundle。RN代码经过Metro打包之后,会生成一个JavaScript bundle文件,原生应用在启动时加载这个文件,然后由JS引擎解释执行,最终形成一个组件树。整个流程和Android/iOS上的RN集成非常像。

我这边大致做了这么几步:

  1. 在RN工程根目录执行打包命令,把业务代码打成一个bundle文件。
  2. 把bundle文件放到OpenHarmony应用工程的资源目录下,或者放到应用沙箱里的指定位置。
  3. 原生壳工程启动时读取bundle,并创建RNRootView和对应的容器。
  4. 指定一个全局组件名作为入口,JS侧注册好这个入口组件。

这里有一个我踩过的坑:bundle路径配置。OpenHarmony应用的文件目录边界比Android还要严格,如果你把bundle放在assets目录,但代码里读的是另一个路径,那加载的时候就会白屏。我建议在最开始做一个简单的“加载成功/失败”提示,比如在原生侧打印一个日志:bundle是否读取成功、bundle大小是多少。排查网络场景下尤其管用,否则你很难确定是文件没读到还是JS执行崩了。

2.3 打通原生组件注册链路

环境搭好之后,下一步就是让自定义原生组件能被RN识别。你可以把这一步理解成“办身份证”:RN侧需要有一个JS组件描述,原生侧需要有一个组件实现类,两边必须对上名字。

我的做法是先从最简单的开始,先注册一个超小的Text组件,从JS侧传一个字符串属性,原生侧渲染出来。这个验证通过后,再进入进度条组件的注册流程。

注册涉及三块关键信息:

  • 组件名:JS和原生协调好,比如BufferedProgressBar。
  • 属性列表:比如progress、bufferProgress、height、colors等。
  • 事件名称:比如onSeekStart、onSeekChange、onSeekEnd。

我在原生侧写了一个组件描述符Provider,然后把它的构造函数登记到组件注册表里。下面是一个概念性表示,具体API因适配层版本不同会有差异:

// 示意代码,具体注册方法以你的适配层为准 const char kComponentName[] = "BufferedProgressBar"; void RegisterMyComponents(Registry* registry) { registry->registerComponentDescriptorProvider( std::make_unique<BufferedProgressBarComponentDescriptorProvider>()); }

JS侧对应的组件声明长这样:

import { requireNativeComponent } from 'react-native'; export interface BufferedProgressBarProps { progress: number; bufferProgress: number; height?: number; trackColor?: string; bufferColor?: string; progressColor?: string; onSeekChange?: (e: { value: number }) => void; } export default requireNativeComponent<BufferedProgressBarProps>('BufferedProgressBar');

如果你用的是新架构,这里一般还需要提供codegen配置或TurboModule声明,让适配层知道每个属性的类型。虽然配置起来会多一些文件,但长远看值得,因为TypeScript接口和原生端类型就能自动对上了,避免手写拼字符串拼错。

3. ProgressBar缓冲进度组件的核心设计与实现

3.1 组件对外API设计:props和事件

一个进度条好不好用,首先看API设计。我当时给自己定的原则是:播放器只需要负责“给数据”,组件本身负责“画得好看、拖得顺手”。所以对外尽量暴露纯数据字段,不要暴露内部绘制逻辑。

最终定下来的接口大概是这样:

export interface BufferedProgressBarProps { max?: number; // 默认100 progress?: number; // 播放进度,0-100 bufferProgress?: number; // 缓冲进度,0-100 height?: number; // 进度条高度,单位dp trackColor?: string; // 底部轨道颜色 bufferColor?: string; // 缓冲进度条颜色 progressColor?: string; // 播放进度条颜色 thumbColor?: string; // 滑块颜色 thumbRadius?: number; // 滑块圆角半径 animated?: boolean; // 是否播放变化动画 onSeekStart?: (value: number) => void; onSeekChange?: (value: number) => void; onSeekEnd?: (value: number) => void; }

这里有一个容易忽略的设计点:bufferProgress和progress没有做大小关联。也就是说,JS侧可以传入bufferProgress小于progress的非法状态。播放器的缓冲进度理论上永远大于等于播放进度,但在并发更新时可能出现短暂乱序。组件内部必须有防御逻辑,比如当bufferProgress小于progress时,先把bufferProgress修正成progress再绘制,否则播放层会被缓冲层盖住。

事件这块我刻意分成三个:开始拖动、拖动过程、拖动结束。播放器通常需要在拖动开始时暂停进度更新,在拖动过程中显示预览时间,在拖动结束后触发seek动作。这样一个组件也能复用到“观看历史进度条”和“列表页视频进度卡片”等不同场景。

3.2 UI实现:双层进度条与缓冲态渲染

前面说了,系统Progress组件不满足需求,所以我在原生侧采用自定义绘制。实现时基于两个“图层”的概念:

  • 底层:轨道背景,铺满整个组件宽度,圆角矩形。
  • 中间:缓冲进度条,宽度等于bufferProgress / max * 总宽度。
  • 顶层:播放进度条,宽度等于progress / max * 总宽度。
  • 最顶层:滑块,画在播放进度的右端点。

代码概念上像这样:

// 以ArkTS为例的绘制示意,不是完整可运行版本 function drawProgressBar(canvas, props) { const totalWidth = canvas.width; const totalHeight = props.height; // 1. 画轨道背景 canvas.drawRoundRect(0, 0, totalWidth, totalHeight, radius, radius, props.trackColor); const safeWidth = totalWidth - 2 * padding; // 2. 画缓冲进度条 const bufferWidth = safeWidth * (props.bufferProgress / props.max); canvas.drawRoundRect(padding, 0, padding + bufferWidth, totalHeight, radius, radius, props.bufferColor); // 3. 画播放进度条 const progressWidth = safeWidth * (props.progress / props.max); canvas.drawRoundRect(padding, 0, padding + progressWidth, totalHeight, radius, radius, props.progressColor); // 4. 画滑块 const thumbCenterX = padding + progressWidth; const thumbCenterY = totalHeight / 2; canvas.drawCircle(thumbCenterX, thumbCenterY, props.thumbRadius, props.thumbColor); }

很多人会觉得双进度条就是“画两个矩形叠加”,原理确实如此。但真正麻烦的是边界处理。进度条末端是有圆角的,宽度很短的时候,比如进度值只有0.5%,如果还按照完整圆角去画,看上去就像一个小圆点,很丑。我的做法是做动态圆角:当实际可绘制宽度小于圆角半径的两倍时,把圆角半径压缩到宽度的一半。这个细节产品不会提,但会很影响观感。

除了静态绘制,还有一个点是触摸热区。播放器里的进度条高度可能只有4dp,如果只在这4dp内响应点击,手指根本没法操作。所以组件在原生侧会有一个“隐形扩展区域”,比如把触摸区域扩展到44dp或48dp,但绘制内容仍然只有4dp高。这在移动端是基本设计常识,但很多做RN自定义组件的人容易忘记。

3.3 props更新与原生化动画

进度条这类组件,最怕的是JS侧每帧去调用setState,然后在原生侧“瞬间跳变”。真实播放器里的进度条应该是平滑滑过去的,所以我在原生侧做了动画差值处理。

当JS侧传来一个新值progress时,原生组件不会直接把绘制位置改成这个值,而是记住一个动画起始值和目标值,然后在UI线程的动画回调里做插值。每帧计算当前值,重新绘制。这样即便JS侧100毫秒才更新一次,视觉效果依然是平滑过渡的。

动画时长不能太长,我是按照“值变化的大小”动态算的。比如变化小于5个百分点,用150ms;变化超过20个百分点,用300ms。这样拖进度条时滑块跟得比较快,播放过程自动前进时不至于拖沓。

另一个关键点是动画和拖拽手势的互斥。如果用户正在用手指拖滑块,组件必须立刻切换到“跟随手指”的模式,不能还做差值动画。否则手指拖到一半,滑块又自己往前滑回原位,体验很糟。我用了一个简单状态机:空闲、动画中、拖拽中。只有空闲和动画中才处理props更新,拖拽中一律忽略外部传入的progress值,全靠手指坐标绘制。

3.4 事件回调:缓冲完成、拖拽交互

为了让播放器感知用户操作和缓冲状态,除了props,组件还需要向JS侧发送事件。这里包括:

  • 缓冲进度达到100%,可以发一个onBufferComplete事件。
  • 播放器主动暂停时,缓冲进度可能继续更新,可以发onBufferUpdate。
  • 用户拖拽时,需要连续上报当前进度值。

在原生侧发送事件时,最忌讳的就是“每条状态都发”。我当时写了一个定时器,每几百毫秒批量发送一次。比如用户拖拽的过程中,手指每移动一个像素就会触发原生侧回调,但发到JS侧的事件至少间隔100ms;每次都只发“当前最新的值”,不做队列累积。这样一个拖拽过程大约只产生10~20次JS事件,播放器监听起来很轻松。

事件载荷里除了value,还可以带上原始坐标和操作类型。我在payload里统一加了一个timestamp字段,方便JS侧做去重和时间排序。因为RN事件在极端情况下可能乱序,如果没有时间戳做判断,老的进度值可能覆盖新的进度值,导致进度条出现回跳的诡异现象。

4. 高频更新场景下的性能优化

4.1 避免JS刷新风暴

真实播放场景里,缓冲进度和播放进度是高频变化的数据。网络快的时候,缓冲进度可能几秒钟就飙到100%;网络不稳定时,又会在某个临界点反复跳动。如果每个数据变化都触发RN组件属性更新,再经过桥接层传给原生,会产生大量通信开销,严重时播放器会卡顿掉帧。

我这边做的核心优化是“原生侧缓存值 + JS侧选择性上报”。原生侧维护一份ProgressBarState,里面保存当前最新的progress和bufferProgress。JS侧不是每次数据源都要推给组件,而是等原生组件自己回传“当前值”之后,再决定是否更新。这样从数据层面把高频更新拦在了原生层。

具体到实现上,我甚至把播放器的bufferProgress更新做成了“direct event”方式,用TurboModule或者事件发射器从原生直接发事件到RN的store,不再走组件props。原因是直接事件可以绕过组件树的re-render,只更新全局状态,然后页面按需订阅。列表页里的进度条能滚动流畅,靠的就是这套机制。

4.2 事件节流与合并:数据更新不上墙

事件节流是最容易见效的优化。我给自己定了一条规则:任何从原生侧发到JS侧的进度事件,两次之间至少间隔200ms。如果值变化小于阈值,比如小于0.5%,也直接吞掉,不发。

这里可以算一笔账。未优化状态下,一次网络波动可能让bufferProgress在5秒内变化60次,对应60次事件和60次JS回调执行。节流之后,5秒内最多发25次事件,但由于做了“只发最新值”,实际有效事件只有10次左右。对于RN应用来说,这个降低幅度对UI流畅度影响很大。

JS侧我也做了合并:收到事件后不会立刻更新React状态,而是放进一个BufferStore。页面通过useIsFocused之类的机制或者定时器,每300ms从store里取一次最新值,统一setState。这样即使原生侧偶尔连发两三个事件,页面重渲染频率依然可控。注意这里不能做防抖,因为进度条是持续变化的,一防抖就会让界面延迟到“看起来卡住”,必须用节流而不是防抖。

4.3 多实例与列表场景优化

如果你只是在播放器页面用,性能压力不大。但如果像我们这样,首页信息流里每个视频卡片都带一个进度条,那问题就不一样了。列表同时滑过屏幕时可能有10个进度条组件活跃,每个都做动画、接收事件,很容易吃掉整机的UI性能。

我的经验是:

  • 列表里的进度条不要启用内置动画,动画只留给播放器详情页的大进度条。
  • 列表项的进度条可以用“快速跳变”模式,不要做差值动画,因为用户滑屏本来就在快速移动,动画没有意义。
  • 多个进度条共用同一个事件总线,不要在原生侧给每个组件单独注册一个EventEmitter。
  • 页面不可见时主动停止动画,比如app退到后台时暂停所有进度条更新动画。

另外还建议把进度条组件做成“无状态绘制”模式:原生侧保存一份“需要绘制什么”的配置,发现配置没变化就直接跳过重绘,避免无意义的onDraw调用。内置的invalidate不是越多越好,过度刷新反而会引发UI合成线程和渲染线程的竞争。

5. 常见问题与排查实录

5.1 组件渲染空白:Registry注册失败

自定义组件最常见的失败就是“页面里明明写了组件,但屏幕上什么也没有”。这类问题99%是注册链路没打通。我排查的顺序是:

  1. 先看原生日志里有没有组件名相关的报错,比如“unknown component”。
  2. 确认JS侧组件名和原生侧注册名完全一致,大小写一个都不能差。
  3. 确认属性名有没有拼错,比如progressColor写成了progresscolor,新架构下类型对不上会直接导致组件配置失败。
  4. 对于新架构,还要检查codegen生成的类型是否和实际注册类型一致。

有一次我查了大半天,最后发现问题出在组件描述符Provider忘记被编译进安装包。代码写得没错,但工程没有把新的源文件包含进构建,自然就没注册进去。所以看到空白时,先确认你改完的原生代码真的重新编译并安装到设备上了。

表格里我整理一下关键排查方向:

症状可能原因快速检查方法
组件空白注册名不一致对比JS requireNativeComponent名称和原生注册名称
组件空白属性类型不匹配检查是否传入数值类型,新架构会强校验
组件空白Provider未编译查看安装包内是否有新编译产物
组件显示但属性无效原生侧没监听属性更新检查updateProps回调有没有正确触发重绘

5.2 进度条闪动/跳变

另一个很恶心的问题是进度条闪动。表现就是播放过程中,播放进度条会突然跳回几秒前,然后再慢慢滑回去。这通常不是渲染问题,而是数据流通路里出现了“旧数据覆盖新数据”。

我们排查到的原因有两个:

第一个是JS侧store没有做时间戳排序。原生事件到达JS的时机不可控,如果事件A是10:00:00.100发出的,事件B是10:00:00.050发出的,但B晚于A到达JS,那么JS如果直接写入store,旧值就会覆盖新值。解决方法是事件里带上毫秒级时间戳,store写入前先比较时间戳,只接受时间更新的数据。

第二个原因是组件内部动画冲突。拖拽结束时会执行一个动画把滑块从“手指位置”滑到“实际seek位置”,但动画过程中播放器又把新的progress值发过来,两者之间发生冲突。我的解决方式是动画执行时设置一个isAnimating标志,如果此时收到外部progress,直接记录到“暂停列表”,等当前动画结束时再处理最新的值。

5.3 onSeek事件没响应或坐标偏

拖拽进度条时,最常踩的坑是坐标换算错误。RN容器和原生组件之间的坐标系不完全一致,尤其是在OpenHarmony设备上如果还涉及状态栏高度、安全区、RTL布局,处理起来更复杂。

我在原生组件里接收触摸事件时,用的是相对于组件自身左上角的坐标,所以计算进度时不需要考虑父容器位置,但需要处理padding。比如进度条最左侧有4dp的padding,触摸点x=10dp,那么实际进度应该按(x - 4) / (totalWidth - 8)去算,而不是x / totalWidth。如果忽略了padding,进度会有几个百分点的偏差,拖拽时尤其明显。

另一个容易忽略的是RTL布局。在RTL模式下,整个进度条的方向会反转,滑块应该从右侧往左侧增长,播放进度应该从右往左增长。如果组件不支持RTL,会直接影响希伯来语、阿拉伯语等地区的体验。原生侧建议用布局方向相关的API来读取,而不是硬编码left。

5.4 内存和包体积问题

加入自定义原生组件之后,包体通常会增加一部分字节。项目的原始包体在几十MB级别,增加这些量级不是问题,但如果做的是嵌入式设备,就需要注意。

我在压缩和复用上有几个习惯:

  • 绘制时尽量复用同一个Paint对象,不要每次onDraw都new对象。
  • 圆角路径对象只在size变化时重建,不要每帧重建。
  • 事件payload里避免携带大量字符串或嵌套对象,最好是一个扁平化的数值结构。
  • 如果多个进度条共享一套颜色主题,可以在原生侧定义主题配置文件,不要在JS侧重复创建颜色字符串。

资源复用还有一个好处:降低GC频率。播放器进度条每帧都在更新,如果每帧创建新对象,内存抖动会很频繁,最终在低端机上体现为肉眼可见的掉帧。

6. 一些经验补充

6.1 渐进式交付:先用假数据跑通链路

我想单独强调一个工作习惯:不要等播放器数据源都准备好再开发组件。第一次接入时,我先用假数据测试进度条。给组件传一个定时器,每500毫秒自动加1,另一个for循环模拟bufferProgress先冲到60%,这样能快速验证原生注册、props更新、事件回传是否正常。等都跑通了,才把真实播放器的数据源接进来。

这样做的好处是出了问题可以明确甩锅范围。假数据阶段出问题,那就是组件或桥接层的问题;换成真实播放器后出问题,那大概率是播放器事件时序、数据源的问题。有一次真数据接入后进度条卡住,我第一时间怀疑是组件动画问题,后来一步步排查发现是播放器在暂停状态下不再发送onProgressUpdate事件,组件根本不知道“该暂停”,还在空转。这种问题如果不做渐进式接入,很容易被混淆在组件和播放器两边。

6.2 日志系统和可视化调试面板

进度条这类组件,肉眼看起来“差不多”,但其实内部状态极其微妙。我开发过程中专门加了一个debug模式:原生侧每隔1秒输出当前progress、bufferProgress、内部动画目标值、当前状态机状态。然后JS侧在界面上画一个悬浮调试面板,实时显示这些数值。

这个调试面板帮了大忙。比如“进度条跳回”的问题,我在原生日志里能清楚看到错误的值更新顺序;再比如“动画和拖拽互斥”的问题,我能在日志里看到状态机在拖拽和动画之间频繁切换。你不需要在正式环境保留这套东西,但开发阶段一定要有,否则只能靠肉眼猜。

6.3 后续还能怎么扩展

如果后续要做得更精致,可以考虑给这个组件增加“预览时间气泡”。拖拽时在滑块上方显示一个时间提示,比如“12:03 / 35:20”,这不仅需要进度条自己绘制,还要和播放器的时间格式化逻辑联动。还有一个方向是支持“断点续播”的标记点,在进度条上用小竖线标出上一次观看的位置,这个在线视频App里很常见,本质上也是同一个原生组件增加数据维度。

从更长远的视角看,这个自定义组件的模式可以复用到很多场景:音量条、亮度条、下载进度、直播延迟指示。只要有“两层进度+拖拽”的需求,底层设计都可以沿用。我在实际使用中最大的体会是,不要把ProgressBar想成一个简单的UI组件,它本质上是一个高频数据接收器加手势交互器,处理好这两端,才能在真机上跑出顺滑效果。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进