ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flutter 短视频与直播 App 跨平台开发实战:架构、性能与上架经验

Flutter 短视频与直播 App 跨平台开发实战:架构、性能与上架经验 短视频和直播这两条赛道单拎出来任何一个都够一个团队忙活大半年更别说把两者塞进同一个 App 里还要跨平台跑在 Android 和 iOS 上。我最初接到这个需求的时候第一反应是这不是给自己找罪受吗——短视频要处理视频流、缓存、预加载、手势切换直播要处理推拉流、低延迟、弹幕、礼物、连麦两套逻辑几乎不共享任何东西却要在一个工程里和平共处。但真正动手做完之后我发现 Flutter 在这个场景下的表现比我预期好得多尤其是它对原生播放器、相机、推流 SDK 的桥接能力配合一套统一的 UI 层确实能把开发成本压下来一大截。这篇就聊聊我在这个项目里踩过的坑、做过的取舍以及那些文档里不会写的实操细节。1. 为什么短视频和直播要共用一个 Flutter 工程1.1 两套业务逻辑的复用边界在哪里很多人一上来就想把短视频和直播拆成两个独立模块甚至两个独立 App理由是它们本来就是不同的东西。这个想法在业务早期是成立的但一旦你考虑到用户体系、关注关系、评论互动、礼物系统、钱包充值、消息通知这些东西就会发现两者有大量重叠。用户刷短视频刷到一个主播点进直播间这个跳转如果跨 App 就会流失掉一大半人。所以从产品角度它们必须在一个 App 里。从技术角度复用边界大致是这样的UI 层几乎可以完全复用比如底部导航、个人主页、评论区、礼物面板、用户头像组件状态管理层可以复用比如登录态、用户信息、关注列表播放器层必须分开短视频用的是短循环播放器直播用的是低延迟推拉流播放器这两者的缓冲策略、首帧优化、错误重试逻辑完全不同数据层部分复用比如用户接口、评论接口可以共用但视频列表接口和直播间接口是两套。我当时的做法是把工程分成core网络、存储、路由、主题、shared通用组件、通用状态、shortvideo短视频模块、live直播模块四个大目录。core和shared是基础shortvideo和live各自独立互不依赖只在路由层做跳转。这样既保证了复用又避免了两个模块互相污染。1.2 Flutter 相比原生双端开发省下了什么如果这个项目用原生做Android 一套 KotliniOS 一套 SwiftUI 层的工作量直接翻倍。短视频的列表、直播间的礼物面板、弹幕、排行榜这些界面在两端几乎一模一样用原生写就是纯重复劳动。Flutter 在这里省下的主要是三块第一块是UI 一致性。短视频的滑动切换、直播间的礼物动画这些交互在 Flutter 里写一遍两端表现完全一致不用去调 Android 和 iOS 的手势差异。第二块是业务逻辑复用。关注、点赞、评论、充值这些逻辑写一遍 Dart 代码两端通用省掉了大量重复的状态管理代码。第三块是迭代速度。Flutter 的热重载在调 UI 的时候太香了尤其是礼物面板这种需要反复微调的界面原生改一次要重新编译Flutter 保存就能看到效果。但 Flutter 也不是万能的。视频播放、相机采集、推流、美颜这些底层能力Flutter 本身做不了必须通过 Platform Channel 调用原生 SDK。这部分工作量并没有省下来反而因为多了一层桥接调试起来更麻烦。所以我的结论是Flutter 适合做这个项目的壳和业务层但媒体层必须老老实实写原生。1.3 工程结构怎么划分才不会后期失控我见过太多 Flutter 项目一开始图省事把所有代码堆在lib下面几个月后变成一坨屎山改一个功能要翻十几个文件。这个项目因为涉及短视频和直播两大块如果不提前规划结构后期一定会失控。我的目录结构是这样的lib/ core/ # 网络、存储、路由、主题、常量 shared/ # 通用组件、通用状态、通用工具 features/ shortvideo/ # 短视频模块 data/ # 数据层repository、model domain/ # 领域层usecase、entity presentation/ # 表现层page、widget、bloc live/ # 直播模块 data/ domain/ presentation/ app.dart # App 入口 main.dart # 主函数每个 feature 内部再按data / domain / presentation三层划分这是比较标准的 Clean Architecture 思路。好处是每个模块的依赖方向是单向的presentation依赖domaindomain依赖data不会出现循环依赖。坏处是文件数量多小功能也要写三层有点重。但对于短视频直播这种体量的项目这点重是值得的。提示不要一开始就追求完美的架构。我建议先用最简单的结构把功能跑通等模块稳定了再重构。过早抽象比不抽象更可怕。2. 短视频模块的核心实现细节2.1 视频列表的预加载与内存控制短视频最核心的体验就是滑得爽而滑得爽的关键在于预加载。用户滑到下一个视频之前下一个视频的首帧必须已经准备好了否则就会出现黑屏或者卡顿。我的做法是维护一个当前索引 前后各一个的预加载窗口也就是同时保持三个播放器实例当前播放的、上一个、下一个。但这里有个坑播放器实例不能无限创建。我一开始图省事每个视频 item 都创建一个播放器结果滑了二十几个视频之后内存直接爆了App 被系统杀掉。后来改成播放器池的方式只维护三个实例滑到新视频时复用最老的那个实例重新设置数据源。这样内存占用就稳定了。具体实现上我用的是PageView配合PageController监听onPageChanged回调在回调里更新播放器池的索引。播放器的创建和销毁都放在一个VideoPlayerManager单例里管理页面只负责调用play(index)和pause()。class VideoPlayerManager { static final VideoPlayerManager _instance VideoPlayerManager._internal(); factory VideoPlayerManager() _instance; VideoPlayerManager._internal(); final Mapint, VideoPlayerController _controllers {}; int _currentIndex 0; void preload(int index, String url) { if (_controllers.containsKey(index)) return; final controller VideoPlayerController.networkUrl(Uri.parse(url)); controller.initialize().then((_) { _controllers[index] controller; }); } void play(int index) { _controllers[_currentIndex]?.pause(); _currentIndex index; _controllers[index]?.play(); } }这段代码看起来简单但实际用的时候要注意initialize()是异步的如果用户在初始化完成之前就滑走了这个 controller 就变成了孤儿必须手动 dispose。我当时的做法是在preload里加一个 token 校验如果 index 已经不在预加载窗口内就直接 dispose 掉。2.2 手势冲突滑动切换和进度拖拽怎么共存短视频的手势有两个上下滑动切换视频左右滑动拖拽进度。这两个手势在 Flutter 里会冲突因为PageView默认会拦截水平方向的滑动导致进度拖拽失效。我的解决方案是自定义一个GestureDetector用onHorizontalDragUpdate手动处理进度拖拽同时把PageView的physics设置成PageScrollPhysics只响应垂直方向的滑动。具体做法是在PageView外面包一层GestureDetector用onHorizontalDragStart / Update / End来处理进度条然后在onHorizontalDragUpdate里判断滑动距离超过阈值才更新进度。GestureDetector( onHorizontalDragStart: (details) { _isDragging true; _dragStartX details.globalPosition.dx; }, onHorizontalDragUpdate: (details) { final delta details.globalPosition.dx - _dragStartX; final progress (_currentProgress delta / screenWidth).clamp(0.0, 1.0); _updateProgress(progress); }, onHorizontalDragEnd: (details) { _isDragging false; _seekTo(_currentProgress); }, child: PageView.builder( scrollDirection: Axis.vertical, controller: _pageController, itemBuilder: (context, index) VideoItem(index: index), ), )这里有个细节onHorizontalDragUpdate里的delta是相对于上一次回调的增量不是相对于起点的绝对距离。我一开始用绝对距离算结果进度条跳来跳去后来改成增量累加才正常。2.3 视频缓存策略什么时候该缓存什么时候不该短视频的缓存策略直接影响用户体验和服务器成本。我的策略是WiFi 环境下预加载前后各两个视频4G 环境下只预加载前后各一个弱网环境下不预加载。这个判断用connectivity_plus插件来做监听网络状态变化动态调整预加载窗口大小。缓存文件的管理也很重要。我用的是flutter_cache_manager设置最大缓存 500MB超过之后按 LRU 策略清理。但这里有个坑缓存文件不能放在 App 的 Documents 目录因为那个目录会被 iCloud 备份用户手机空间不够的时候会出问题。应该放在getTemporaryDirectory()返回的临时目录里系统会自动清理。注意iOS 上如果缓存文件放在 Documents 目录上架审核的时候可能会被拒理由是占用用户 iCloud 空间。这个坑我踩过后来全部改成临时目录才过审。3. 直播模块的推拉流与低延迟优化3.1 推流 SDK 的选型和桥接方式直播的推流能力 Flutter 本身做不了必须用原生 SDK。市面上常见的选择有几个我最终选的是一个支持 RTMP 协议的推流 SDK通过 Platform Channel 桥接到 Flutter 层。桥接的方式有两种MethodChannel和EventChannel。MethodChannel 用于调用原生方法比如开始推流、停止推流、切换摄像头EventChannel 用于接收原生回调比如推流状态变化、网络质量变化、错误回调。桥接的代码大致是这样的class LivePushManager { static const _methodChannel MethodChannel(live_push/method); static const _eventChannel EventChannel(live_push/event); Futurevoid startPush(String url) async { await _methodChannel.invokeMethod(startPush, {url: url}); } StreamPushEvent get events { return _eventChannel.receiveBroadcastStream().map((event) { return PushEvent.fromMap(MapString, dynamic.from(event)); }); } }原生端Android 用 KotliniOS 用 Swift分别实现对应的 MethodChannel 和 EventChannel 处理逻辑。这里要注意的是EventChannel 的 Stream 只能被监听一次如果多个页面同时监听会报错。我的做法是在LivePushManager里用一个StreamController.broadcast()做中转把 EventChannel 的事件转发出去这样多个页面可以同时监听。3.2 低延迟的关键GOP 和缓冲区设置直播延迟高不高主要看两个参数GOP 大小和播放器缓冲区。GOP 是 Group of Pictures 的缩写指的是一组连续画面的长度GOP 越大关键帧间隔越长延迟越高。一般直播场景下 GOP 设置在 1-2 秒比较合适太低会导致码率上升太高会导致延迟增加。播放器缓冲区是另一个关键。缓冲区越大抗抖动能力越强但延迟越高缓冲区越小延迟越低但网络波动时容易卡顿。我的做法是动态调整缓冲区网络好的时候缓冲区设小一点比如 200ms网络差的时候自动调大比如 1s。这个逻辑在原生播放器层实现通过 EventChannel 把网络质量回调给 Flutter 层Flutter 层再决定要不要调整。实测下来这套方案能把端到端延迟控制在 1.5 秒以内比默认配置的 3-5 秒好很多。但要注意延迟和流畅度是一对矛盾不能一味追求低延迟否则用户看到的就是不断卡顿的画面。3.3 弹幕和礼物的性能优化直播间的弹幕和礼物是性能杀手。弹幕如果每来一条就创建一个 Widget几分钟后就会卡成幻灯片。我的做法是用ListView.builder配合一个固定长度的消息队列只渲染可视区域内的弹幕超出范围的直接丢弃。礼物的动画更麻烦因为礼物动画通常是序列帧或者 Lottie同时播放多个会占用大量 GPU 资源。我的优化策略是礼物动画做队列化处理同一时间只播放一个礼物动画其他的排队等待。如果短时间内收到大量礼物就合并显示比如XXX 送出 10 个火箭而不是播放 10 次动画。这个策略在实测中效果很好即使同时有几百个用户送礼物界面也不会卡。class GiftQueue { final QueueGiftEvent _queue Queue(); bool _isPlaying false; void add(GiftEvent event) { _queue.add(event); _tryPlayNext(); } void _tryPlayNext() { if (_isPlaying || _queue.isEmpty) return; _isPlaying true; final event _queue.removeFirst(); _playAnimation(event).then((_) { _isPlaying false; _tryPlayNext(); }); } }提示礼物动画的播放时长不要超过 3 秒否则队列会越积越长用户看到的是几分钟前的礼物体验很差。4. 跨平台适配中那些让人头疼的差异4.1 Android 和 iOS 的权限处理差异相机和麦克风权限在 Android 和 iOS 上的处理方式完全不同。Android 需要在AndroidManifest.xml里声明权限运行时还要动态申请iOS 需要在Info.plist里声明用途描述运行时系统会自动弹窗。Flutter 的permission_handler插件把这两套逻辑统一了但实际用的时候还是有很多坑。最大的坑是iOS 的权限弹窗只能弹一次。如果用户第一次拒绝了第二次调用request()不会弹窗直接返回 denied。这时候必须引导用户去系统设置里手动开启。Android 也有类似的问题如果用户勾选了不再询问后续调用也不会弹窗。我的做法是封装一个PermissionHelper统一处理这些情况如果权限被永久拒绝就弹一个自定义对话框引导用户去设置页。Futurebool requestCameraPermission() async { var status await Permission.camera.status; if (status.isGranted) return true; if (status.isPermanentlyDenied) { // 引导用户去设置页 await openAppSettings(); return false; } status await Permission.camera.request(); return status.isGranted; }4.2 视频播放器的平台差异Android 和 iOS 的视频播放器底层实现不同导致一些行为差异。比如iOS 的 AVPlayer 默认不支持后台播放需要在Info.plist里配置UIBackgroundModesAndroid 的 ExoPlayer 默认支持后台播放但需要在AndroidManifest.xml里配置 Service。再比如iOS 的视频旋转处理和 Android 不一样iOS 需要手动处理AVPlayerLayer的videoGravityAndroid 则通过AspectRatio自动处理。这些差异在 Flutter 层很难完全抹平我的做法是在原生播放器层做适配Flutter 层只调用统一的接口。比如定义一个VideoPlayerAdapter接口Android 和 iOS 各自实现Flutter 层通过 Platform Channel 调用不关心底层是 ExoPlayer 还是 AVPlayer。4.3 字体和图标在不同平台的显示差异Flutter 默认使用系统字体Android 上是 RobotoiOS 上是 San Francisco。这两个字体在字重、行高、字间距上都有差异导致同一个界面在两端看起来不一样。我的做法是统一使用自定义字体把字体文件打包进 App两端都用同一个字体渲染。这样虽然增加了包体积一个中文字体动辄几 MB但保证了 UI 一致性。图标也有类似的问题。Flutter 自带的Icons在两端表现一致但如果用了平台特定的图标比如 iOS 的CupertinoIcons在 Android 上就会显得格格不入。我的原则是能用 Material Icons 就用 Material Icons实在需要平台特定样式的地方用Platform.isIOS做条件判断。5. 性能监控与线上问题排查5.1 卡顿监控怎么定位是 Flutter 层还是原生层的问题短视频和直播 App 最怕的就是卡顿但卡顿的根因可能在 Flutter 层也可能在原生层。我的做法是双端埋点Flutter 层用SchedulerBinding.instance.addTimingsCallback监控每一帧的耗时超过 16ms 就上报原生层用各自的性能监控工具Android 用ChoreographeriOS 用CADisplayLink监控渲染耗时。两边都上报到同一个监控平台通过时间戳对齐就能判断卡顿发生在哪一层。实测下来大部分卡顿都发生在 Flutter 层的 UI 构建阶段尤其是列表滚动的时候。原因通常是build方法里做了耗时操作比如 JSON 解析、图片解码。解决办法是把这些操作移到compute里异步执行或者用cached_network_image做图片缓存。5.2 内存泄漏那些不容易发现的坑Flutter 的内存泄漏比原生隐蔽得多因为 Dart 有 GC很多泄漏不会立刻表现出来。我踩过的几个坑StreamSubscription 忘记 cancel、AnimationController 忘记 dispose、Timer 忘记 cancel、Platform Channel 的回调持有页面引用。这些泄漏在单个页面不明显但短视频和直播都是长时间停留的页面累积起来就会导致内存暴涨。我的做法是每个 StatefulWidget 都写一个dispose方法把所有需要释放的资源都列出来逐一释放。同时用leak_tracker插件做自动化检测在 CI 里跑发现泄漏就阻断合并。override void dispose() { _subscription?.cancel(); _animationController?.dispose(); _timer?.cancel(); _pageController.dispose(); _scrollController.dispose(); super.dispose(); }5.3 线上崩溃的快速定位方法线上崩溃最麻烦的是没有堆栈尤其是原生层的崩溃Flutter 层根本捕获不到。我的做法是接入原生的崩溃收集 SDKAndroid 用 Firebase CrashlyticsiOS 用 Bugly同时在 Flutter 层用FlutterError.onError和PlatformDispatcher.instance.onError捕获 Dart 异常。两边都上报到同一个平台通过设备 ID 和时间戳关联就能还原崩溃现场。还有一个技巧是在关键路径上加日志比如推流开始、播放器初始化、页面跳转这些日志在崩溃时能帮助定位问题。但日志不能太多否则会影响性能我的原则是只在关键节点打日志且日志级别可动态调整。注意线上日志不要打印用户敏感信息比如手机号、身份证号否则会有合规风险。6. 从开发到上架那些没人告诉你的细节6.1 包体积优化怎么把 APK 从 80MB 压到 40MB短视频和直播 App 的包体积很容易失控因为要打包推流 SDK、播放器 SDK、美颜 SDK、字体文件、图片资源。我一开始打出来的 APK 有 80MB用户下载意愿很低。后来做了几轮优化压到了 40MB 左右。优化手段主要有几个开启混淆和资源压缩Android 用 R8iOS 用 bitcode、移除未使用的原生库比如推流 SDK 里用不到的功能模块、图片资源用 WebP 格式、字体文件做子集化只保留用到的字符、so 文件按 ABI 拆分用 App Bundle 或者 split APK。其中效果最明显的是 so 文件拆分因为推流 SDK 的 so 文件动辄十几 MB拆成 armeabi-v7a 和 arm64-v8a 之后单个 APK 能省下一半。6.2 上架审核短视频和直播类 App 的特殊要求短视频和直播类 App 上架审核比普通 App 严格得多因为涉及内容合规。我踩过的坑包括必须有内容审核机制人工机器、必须有举报和拉黑功能、必须有未成年人保护措施、必须有直播资质这个因地区而异。这些要求在审核指南里都有写但很容易被忽略。我的建议是提前准备好审核材料包括内容审核流程说明、举报处理流程、未成年人保护方案、相关资质证明。审核的时候把这些材料一起提交能大大加快审核速度。另外审核期间不要开直播功能等审核通过之后再开否则容易被拒。6.3 灰度发布和热更新策略短视频和直播 App 的迭代频率很高如果每次都全量发布风险太大。我的做法是灰度发布先发 5% 的用户观察崩溃率和关键指标没问题再逐步扩大到 20%、50%、100%。灰度发布用 Google Play 的分阶段发布或者国内的灰度平台都能做。热更新方面Flutter 官方不支持热更新但可以通过一些方案实现比如把 Dart 代码编译成 so 文件动态加载。不过这个方案有合规风险我最终没有采用而是选择了原生层的热更新Android 用 TinkeriOS 用 JSPatch 的替代方案只更新原生代码Flutter 代码还是走正常发版流程。7. 一些零散但重要的经验7.1 状态管理选型Bloc 还是 Provider这个项目我最终选了 Bloc原因是短视频和直播的状态都比较复杂涉及多个异步流播放状态、网络状态、用户状态Bloc 的事件驱动模型更适合这种场景。Provider 更适合简单的状态共享比如主题切换、登录态用在短视频和直播上会显得力不从心。但 Bloc 的样板代码确实多一个简单的功能要写 Event、State、Bloc 三个文件。我的做法是只在复杂模块用 Bloc简单模块用 Provider 或者 setState不搞一刀切。比如礼物面板这种状态简单的组件直接用StatefulWidget就够了没必要上 Bloc。7.2 网络层的重试和降级策略短视频和直播对网络的依赖很强网络抖动会导致播放失败、推流中断。我的做法是在网络层加重试和降级请求失败时自动重试 3 次每次间隔递增1s、2s、4s如果重试都失败就降级到备用接口或者显示缓存数据。直播推流中断时自动尝试重连重连失败就提示用户切换网络。这里有个细节重试不能无限制否则会在弱网环境下疯狂请求耗电又耗流量。我的做法是设置一个最大重试次数和最大重试时长超过就放弃提示用户手动重试。7.3 测试策略哪些必须测哪些可以跳过短视频和直播的测试成本很高因为涉及真实的视频流和网络环境。我的策略是分层测试单元测试覆盖核心逻辑比如播放器池的管理、礼物队列的处理Widget 测试覆盖关键 UI比如评论区、礼物面板集成测试覆盖主流程比如从列表页进入直播间、发送弹幕、送礼物。真机测试只测关键路径不追求全覆盖。真机测试的重点是弱网测试和低端机测试。弱网测试用工具模拟 2G/3G 网络看播放器是否能正常降级低端机测试用几年前的千元机看是否能流畅运行。这两个场景是线上问题的高发区必须重点覆盖。7.4 团队协作Flutter 和原生开发的边界怎么划这个项目涉及 Flutter 开发和原生开发两个角色边界划分很重要。我的做法是Flutter 团队负责 UI 和业务逻辑原生团队负责媒体层和系统能力。两边通过 Platform Channel 的接口定义来协作接口一旦确定就不轻易改动改动必须双方评审。接口定义用文档管理每个接口都要写清楚方法名、参数、返回值、错误码。我见过太多项目因为接口定义不清晰导致两边联调的时候扯皮。提前把接口定清楚能省下大量沟通成本。7.5 那些让我印象深刻的线上问题最后分享几个印象深刻的线上问题。第一个是播放器在后台被系统回收导致用户切回前台时黑屏。解决办法是在AppLifecycleState变化时暂停播放器切回前台时重新初始化。第二个是直播间的弹幕在弱网下堆积用户看到的是几分钟前的弹幕。解决办法是给弹幕加时间戳超过 5 秒的弹幕直接丢弃。第三个是礼物动画在低端机上卡顿原因是 Lottie 动画太复杂。解决办法是降级到静态图片或者简化动画。这些问题在开发阶段很难发现只有真实用户量上来之后才会暴露。我的经验是上线之后要密切关注用户反馈和监控数据尤其是崩溃率和卡顿率发现问题及时修复。短视频和直播这类 App用户体验就是生命线一点卡顿都可能导致用户流失。
返回列表