
1. 项目背景90 天倒计时背后的技术选型逻辑1.1 团队配置与需求清单先交代一下当时的情况。团队一共 12 个人4 个客户端、4 个后端、2 个设计、1 个测试外加一个我这种既盯进度又写代码的半客户端半架构角色。老板给的期限是 90 天目标明确到不能再明确做出一款能同时承载短视频信息流和直播功能的 App要求双端同步上线iOS 和 Android 都不能掉队。需求拆完之后大概是这么个量级短视频模块首页信息流、上下滑动切视频、双击点赞、评论弹层、关注/推荐 Tab、作品发布拍摄 相册选择 滤镜 配乐直播模块开播、观众端拉流、弹幕聊天、礼物面板、连麦最后砍了用户体系手机号登录、微信授权、关注关系、个人主页、作品列表通用能力分享、消息通知、搜索、设置这套需求放到原生双端去做12 个人 90 天基本是天方夜谭。就算客户端全员爆肝光双端 UI 还原、双端 Bug 修复、双端审核节奏协调就能耗掉一半人力。所以 Flutter 几乎是唯一合理的选择理由后面细说。1.2 选型 Flutter 之前我们做过的对比很多团队在选型阶段会纠结 Flutter 还是 React Native我们内部也吵过一轮。当时我拿了三个核心场景去压测对比长列表滚动、视频播放器嵌入、相机画面实时预览。RN 在长列表和普通业务页面上确实没问题但在视频密集型场景存在两个痛点一是原生模块和 JS Bridge 的通信开销在快速滑动时会放大二是复杂的自定义手势比如视频页的双击、滑动反馈联动在桥接层写起来很别扭。Flutter 的渲染层完全自绘所有手势、动画都在 Dart 层闭环这种场景反而顺。另外我们评估了一下团队现状4 个客户端里只有 1 个写过两年 Flutter其他 3 个都是原生转过来。Flutter 的学习曲线对 Android 转岗的人尤其友好因为它的 widget 嵌套思维和 Android 的 View 层级很像iOS 转岗的人前期会不太适应但基本上两周能进入状态。这个字段我们赌对了后面开发效率确实没有卡在语言上。还有一点是热重载。短视频页的 UI 调起来很频繁——间距、字号、阴影、按钮位置这些都是视觉敏感细节。Flutter 的热重载能在不丢失页面状态的情况下秒级看到效果这在 90 天的高强度迭代里帮了大忙。原生双端改一个间距要编译两次Flutter 这边一天能调十几轮 UI 还不带烦的。2. 短视频 Feed 的底层实现播放器并不是唯一的主角2.1 为什么我们最后选择了 video_player 自研播放器封装短视频 App 最核心的组件就是那个视频播放器。选型的时候我们对比过官方 video_player、第三方插件 chewie、以及自己用平台通道封装 ExoPlayer / AVPlayer 的方案。先说结论我们用了 video_player 做基础能力但在它外面包了一层自研的 Controller 和缓存策略。官方插件的问题在于它的 API 太基础了只有play、pause、setLooping、setVolume这种原子操作完全不涉及预加载、资源释放、清晰度切换这些业务级能力。而 chewie 这种封装的 UI 风格太固定定制起来甚至比自己写还费劲。我们自己封装的这一层主要做了三件事多实例管理管理当前播放的 Controller以及预加载队列里的若干个空闲 Controller清晰度切换通过替换dataSource实现切换时保留播放进度异常状态机把空数据、加载中、可播放、播放失败、缓冲中等状态统一暴露给 UI 层这里有一个很关键的认知短视频页卡顿的原因通常不是播放器解码能力不够而是播放器实例的生命周期没有管好。很多团队写短视频页上来就是 ListView.builder 里每个 item 放一个播放器滑到哪里播到哪里滑走了也不管。结果就是你同时在内存里维护了十几个播放器每个播放器都在解码视频帧内存直接飙到 800MB不卡才怪。2.2 预加载池与无限滑动分页我们的方案是做一个固定容量的预加载池核心思路参考了 RecyclerView 的复用机制界面上最多同时存在 3 个播放器当前播放的、上一个、下一个屏幕滑动时进入预加载范围的视频通常是当前位置往后 2 个提前创建 Controller 并加载首帧距离超过 3 个的 Controller 直接dispose()把解码资源和内存还给系统在分页逻辑上我们用了一个比较朴素的方案每次滑到列表倒数第 4 个 item 时触发下一页请求。接口返回的数据会带上视频封面图 URL、视频地址、宽高比、作者信息、点赞数这些字段客户端拿到后先把封面图渲染出来给用户一个立即可见的反馈视频流在后台异步加载。这里有一个PageStorageKey的坑。如果列表页用了PageStorageKey短视频的滚动位置确实能保存但播放器的状态不会帮你保存。用户滑到第 10 个视频切到别的 Tab 再回来滚动位置在第 10 个但播放器不一定在播第 10 个。我们的处理方式是在PageStorageKey之外自己维护了一个currentIndex的全局状态页面重新可见时根据当前 index 重建对应 Controller。代码层面预加载池的核心大概是这个样子class VideoPreloadController { final Mapint, VideoPlayerController _pool {}; int _currentIndex -1; void onPageChanged(int index, ListVideoItem data) { final toLoad {index, index 1, index - 1}; final toDispose _pool.keys.where((k) !toLoad.contains(k)); toDispose.forEach((k) { _pool[k]!.dispose(); _pool.remove(k); }); toLoad.forEach((i) { if (i 0 || i data.length) return; _pool.putIfAbsent(i, () { final controller VideoPlayerController.networkUrl(data[i].url); controller.initialize().then((_) { if (_currentIndex i) controller.play(); }); return controller; }); }); } }注意这里没有直接用initialize()的 Future 去 await而是让每个 Controller 自己初始化初始化完成后由状态层决定是否真正开始播放。这个细节决定了快速滑动时用户看到的画面是瞬间出帧还是卡一下才出实测下来效果差距非常明显。3. 直播链路搭建推流、拉流与低延迟的根本解法3.1 主播端推流从 Camera 插件走到 RTMP直播模块是 90 天里最难啃的一块骨头因为它不是纯客户端问题而是客户端 服务端 协议三端的配合。主播端推流方案对比下来我们选了Camera 插件采集画面 原生层封装 RTMP 推流。Flutter 生态里没有完全成熟的推流 SDK有的几个要么停止维护要么只支持 Android。最终的做法是写了一个 platform channel 的封装Flutter 层管 UI 和采集配置原生层管编码和推流。iOS 端直接用AVCaptureSession采集VideoToolbox硬编码 H.264AudioToolbox编码 AAC然后用libRTMP之类的库推流。Android 端用的是 Camera2 API 加 MediaCodec 硬编。这里必须用硬编码软编码在移动端根本扛不住高分辨率推流发热和掉帧会很严重。推流参数的配置我直接给结论视频编码H.264 Baseline Profile码率 2500kbps分辨率720p竖屏 720x1280帧率 30fps音频编码AAC 44.1kHz 双声道码率 128kbps关键帧间隔2 秒这几个参数是我们压测了不同机型之后权衡的结果。帧率 30fps 保证了流畅度关键帧间隔 2 秒是为了降低观众端拉流的延迟——如果一个关键帧间隔太长新进直播间的观众要等好久才能看到画面。3.2 观众端拉流低延迟 HLS 的取舍拉流端我们同时支持了 RTMP 和 HLS 两种方式。RTMP 延迟低1-3 秒但对播放器的兼容性和弱网表现不如 HLS。HLS 的常规延迟在 5-10 秒但胜在稳定苹果和安卓的浏览器都能播。我们的做法是App 内优先用 RTMP 拉流降级时自动切 HLS。具体触发降级的条件是当前网络类型切换比如 Wi-Fi 切到 4GRTMP 连续 3 次连接超时CPU 占用率超过 80%低端机硬解高码率会明显发热这个双协议方案帮我们解决了一个很实际的问题直播高峰期服务端负载大的时候 RTMP 流可能会有抖动切到 HLS 后虽然延迟高了几秒但至少用户不会直接看到加载失败。不过要注意延迟高到一定程度会影响互动体验。我们统计过观众看到主播说欢迎新进来的朋友到自己发弹幕被主播看到如果延迟超过 8 秒互动感会大打折扣。所以最终 HLS 我们用的是分片时长 2 秒的低延迟配置整条链路的延迟控制在 4-6 秒体感上勉强可以接受。3.3 直播聊天WebSocket 的消息并发处理直播模块除了画面互动消息弹幕、礼物、进场通知全靠 WebSocket 支撑。这块的坑在于 Flutter 的WebSocketChannel底层是基于 Stream 的消息量大时如果 UI 侧处理不及时Stream 会积压导致页面越来越卡甚至内存膨胀。我们处理的方式是在消息入口做两层分流数据层用自定义的StreamController把弹幕和礼物消息按类型分发到不同的广播流UI 层弹幕列表用一个固定长度的滑动窗口最多保留 200 条超出自动淘汰礼物消息和弹幕消息的处理优先级也做了区分弹幕是高频低价值可以合并渲染礼物是低频高价值必须每条都触发动画效果。如果礼物和弹幕混在一个 Stream 里做同样的处理很容易出现送礼动画被弹幕淹没的问题。4. 工程架构与状态管理90 天迭代不被代码追上的前提4.1 Riverpod 的模块拆分状态管理我们没选 Bloc选了 Riverpod。理由很简单这个项目的状态类型太多——短视频页有播放器状态、有数据加载状态直播页有连接状态、有消息流用户体系有登录状态、关注状态。Bloc 在这种多状态交织的场景里要写大量的 Action 和 Event代码量会膨胀得很快。Riverpod 的声明式依赖和自动重建特性反而更贴合。模块拆分上我们按功能域划分而不是按页面划分lib/ ├── core/ # 网络、存储、工具函数 ├── shared/ # 公共 UI 组件、通用数据模型 ├── feed/ # 短视频信息流 ├── live/ # 直播模块 ├── user/ # 用户体系、登录注册 ├── publish/ # 作品发布、拍摄、编辑 └── app.dart # 应用入口每个功能域内部再分data / domain / presentation三层。Data 层管接口请求、数据持久化Domain 层定义数据模型和业务规则Presentation 层管 widget 和页面状态。这套结构最大的好处是后来加需求的时候能清楚地知道代码该放哪儿。举一个实际的例子后来产品要在短视频页加一个附近的人Tab我们只需要在 feed 域里加一个数据源和一个页面实现完全不需要碰直播和用户域的代码。如果当时用的是大杂烩的单模块结构这种改动就很容易引发回归。4.2 网络层与错误重试网络层我们封装了基于dio的统一请求入口所有接口请求必须经过这一层不允许业务代码直接 new Dio。这样做的目的有两个统一加鉴权 header、统一处理错误码、统一打印日志。最容易踩坑的是token 过期后的刷新机制。我们的处理是在dio拦截器里检测 401 状态码发现 token 过期就自动调刷新接口拿新的 token然后把失败的请求重新放回队列。注意这里有一个并发问题如果同时有 10 个请求都返回 401理论上会触发 10 次刷新请求。我们的解决方案是设置一个isRefreshing标志位刷新期间其他的 401 请求进入等待队列刷新完成后再统一重放。错误重试要考虑幂等性。GET 请求可以放心重试但 POST 请求比如发评论、送礼不能无条件重试否则用户手机稍微一抖服务端就收到两条相同的数据。我们给 POST 请求加了一个requestId参数服务端根据requestId做幂等判断。这个细节很多人忽略但线上真的会出这种事故——直播弹幕发不出去用户疯狂点发送结果网络恢复后服务端收到几百条相同弹幕。5. 性能优化帧率、内存和发热绕不开的三个关口5.1 列表里视频播放器的内存回收前面提到了预加载池的思路这里再补几个实际优化中发现的细节。Flutter 的ListView.builder本身是有 item 回收机制的但回收的是 widget不是播放器。如果你的每个 item 都持有一个VideoPlayerController即使 widget 被回收Controller 内部的原生解码器资源也不会自动释放。我们专门写了一个PlayerLifecycleManager来监听滚动事件在 onScroll 回调里实时计算当前可见的 item 范围把超出范围的 Controller 立即 dispose。内存曲线在这个改动前后对比非常明显改之前快速滑 100 条视频内存能涨到 700MB 然后被系统杀掉改之后内存稳定在 250MB 左右。这个优化是短视频 App 能不能用的分水岭不做的话性能测试都过不了。还有一个小坑Flutter 的Image.network如果直接用在短视频封面图上快速滑动时内存也会像坐火箭一样涨。因为Image.network默认不带缓存策略每张新图片都会加载到内存。我们的做法是统一用cached_network_image插件并且合理设置CacheManager的最大存储数量避免缓存无限膨胀。5.2 Impeller 带来的渲染变化Flutter 3.7 之后 Skia 渲染开始分阶段切到 Impeller。对我们这个项目来说最直观的感受是iOS 上的滚动掉帧明显减少了。Skia 在 iOS 上跑复杂的图层效果比如高斯模糊、阴影叠加会有性能瓶颈短视频页的评论弹层、直播页的礼物动画都有这种效果切到 Impeller 后帧率稳定很多。但要注意Impeller 当时在 Android 上默认是没有开启的现在也是部分机型才默认开启。我们的做法是走了一版针对低端 Android 机型的降级策略检测到设备 GPU 性能较差通过设备型号列表或者跑分白名单判断就关闭模糊效果和部分阴影保证核心功能的流畅度。Flutter 的性能优化有一个容易忽视的点Dart 层的计算开销远小于像素渲染开销。所以优化重心要放在减少不必要的 build、减少图层合成、避免大面积的半透明图层叠加而不是纠结于某个耗时的 Dart 函数。我们团队一度在找 Dart 层的性能瓶颈后来才发现瓶颈在opacity那一层——一个全屏半透明遮罩的代价远大于 10 个setState。5.3 启动时间与首帧优化短视频和直播类的 App 对启动速度要求很高。用户打开 App 如果 3 秒内看不到内容流失率会明显上升。我们的启动路径是启动页原生→ Flutter 首页 → 短视频 Feed 数据加载。第一个优化点是把 Flutter 引擎的预创建提前到启动页展示期间。原生启动页加载的同时在后台启动 FlutterEngine页面跳转时直接复用这个引擎省掉了 Flutter 初始化最耗时的几百毫秒。第二个优化点是首屏数据的本地缓存。用户第一次进入 Feed 页时先渲染本地缓存的内容可能是上一次退出时留下的数据同时后台请求最新的 Feed。这个策略虽然技术含量不高但对启动阶段的用户体验提升非常大。第三个优化点是封面图优先于视频加载。前面提到过Feed 接口返回的数据里封面图 URL 和视频 URL 是分开的UI 层先渲染封面图视频初始化完成后无缝切换。如果视频加载失败封面图依然能展示至少用户看到的不是一块黑屏。6. 上线前的真机测试与拦截过的崩溃6.1 播放器回归清单90 天交付的项目测试资源肯定不够。我们把有限的人力集中在最容易出问题的模块上——视频播放器和直播链路列了一份播放器回归清单弱网环境3G、4G 降速下视频是否能正常加载和续播快速滑动时是否存在黑屏、花屏、声音串流切到后台再回前台播放器状态是否正确恢复视频播放中接听电话、打断音频焦点后能否恢复低端机型内存 4GB 以下长时间使用是否崩溃不同清晰度切换后进度是否保持一致视频文件格式兼容性测试H.264 / H.265 / 不同封装格式其中声音串流这个问题是我们真实遇到过的上滑一个新的视频上一个视频的声音还能持续几百毫秒。原因是切换 Controller 时没有先暂停旧的 Controller旧解码器的音频缓冲还在输出。好在排查链路不长slide 到新 item 时统一执行暂停旧的 切换新的 播放新的三步操作就解决了。6.2 审核和发布风险双端同步上线的时间节点最怕的是审核被卡。我们提前准备了应对策略iOS 审核有一个坑是直播类 App 必须包含内容和用户举报机制。如果你的 App 有直播功能Apple 审核员比较容易关注人机交互审核、内容审核功能。我们在 App 里提前集成了举报入口和信息反馈页面审核备注里也写清楚了这些机制最终顺利通过。Android 端主要是权限和合规问题。Android 14 及之后的版本对麦克风和相机的权限提示要求更严格我们在权限弹窗的文案里明确说了用途并提供了拒绝授权后的页面降级逻辑。还有一个容易忽略的发布问题热更新。Flutter 的代码更新比原生灵活一些但不能像 Web 那样随时发版。我们计划了一次紧急热修的机制——通过远端配置文件控制部分功能的开关和参数比如 Feed 请求的分页大小、某个接口的域名切换不用发版就能应急调整。这个方案虽然朴素但关键时刻能救命。7. 回头看哪些技术决策让我后悔哪些我还会接着用90 天时间紧但正是因为紧才逼着我们在关键节点上做了一些正确决定也暴露了一些现在还觉得遗憾的地方。先说说对的选择用 Flutter 是对的但前提是团队不能全是 Flutter 新手。我们庆幸的是有一个有 Flutter 经验的同事来定基础架构方向避免了从零踩坑。如果他也没经验大概率会卡在路由、状态管理、插件桥接这些环节上90 天根本做不完。自研播放器封装层是对的。直接套现成的第三方 UI 组件看起来省事但后期业务定制时会被各种限制拖后腿。自己在官方插件之上封装一层控制逻辑看起来多写了代码实际节省了后期魔改的时间。直播模块用原生推流是对的。纯 Flutter 生态目前真的没有可靠的推流方案在这个环节上混编反而能拿到最优性能。再说不满意的测试留得太晚了。前两个月基本都在写新功能测试只做了冒烟验证。第三个月集中联调的时候播放器、直播、消息三个模块交叉暴露了一堆问题修 Bug 的时间远超预期。如果时间能重来我会在前两周就搭好自动化测试的脚手架至少保证核心链路的 UI 测试和状态逻辑测试。网络层的重试策略应该更早设计。当时因为要求紧迫很多接口的请求逻辑是复制粘贴改的后来统一收敛到网络层时已经产生了一些不一致。如果在项目第一天就统一封装好后面省下的排查时间会非常可观。直播连麦功能被砍掉是明智的决定但产品侧一开始不死心。这件事给我们的教训是需求范围要在第一天就跟产品对齐清楚什么功能是 MVP 必须的什么功能可以放二期。如果不做这个决策连麦模块的开发量至少还要再加 15 天整个项目就得延期。最后说一个个人感受Flutter 做内容型产品短视频、直播、图文社区是越来越合适的因为这种产品拼的是 UI 交互体验和业务迭代速度而这些正好是 Flutter 的强项。别被Flutter 只能做工具类 App的偏见影响我们这套方案跑通了你也可以。