多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

用Flutter打造鸿蒙儿童故事播放器:跨平台开发的踩坑实录

用Flutter打造鸿蒙儿童故事播放器:跨平台开发的踩坑实录 这事得从我家三岁半的小朋友说起。每天睡前他都雷打不动要听两段故事手机上其实装了不少儿童故事App可打开就是几秒的开屏广告跳过广告接着弹“开通会员解锁更多故事”有一次他自己划拉屏幕差点点进一个付费套餐页。我当场决定自己写一个干干净净的故事播放器。正好我手头的设备比较杂主力机是安卓家里有台鸿蒙系统的平板老人那边还有一部旧手机。要覆盖这么多平台原生单独维护两套基本不现实于是这个项目的技术路线很快就定了——用Flutter做跨平台UI把鸿蒙作为重点适配目标做一款完全免费、无广告、无内购的少儿故事播放应用。这篇博文就是把整个项目的真实经历整理出来从功能边界、播放器设计到鸿蒙适配踩坑、免费内容合规再到打包上架讲讲那些文档里很少提到的细节。1. 项目定位一个程序员家长的哄睡解决方案1.1 需求边界怎么画的动手前我先花了几天把需求边界想清楚。市面上儿童故事应用的功能那叫一个多有社区、有打卡、有积分商城、还有“AI老师”分析孩子发音。但这些统统不是我的核心场景。我的场景就三个孩子睡前要听故事、孩子在车上想听故事、老人帮忙带娃时能一键播放。所以第一版只保留四条核心链路故事列表按年龄段、故事类型分类支持下拉刷新和本地搜索。播放器播放、暂停、上一首、下一首、倍速、定时关闭。播放记忆每个故事记录上次听的位置下次打开直接续播。收藏孩子喜欢的反复听家长一键收藏。其他所有花哨功能全部砍掉。这也是我想强调的一点很多做儿童应用的朋友一上来就规划社区、音频直播、AR互动最后开发周期拖得很长。少儿故事播放的核心价值就是“让家长省心、让孩子安静听”功能边界画小了开发反而顺利。1.2 为什么是Flutter加鸿蒙而不是其他组合我做过一段时间的原生开发也用过React Native。选Flutter主要有三个原因。第一个是UI一致性。Flutter的渲染引擎是自绘的不依赖系统组件意味着同一套界面在安卓、鸿蒙、iOS上能保持几乎一致的观感。对儿童应用来说统一的圆角、大按钮、固定配色很重要我不想每换一台设备就重新适配一遍控件样式。第二个是社区生态。像音频播放、本地数据库、状态管理这些常见需求Flutter社区都有成熟方案虽然不是所有插件都原生支持鸿蒙但有ohos适配分支的项目越来越多改造成本可控。第三个是我个人对Flutter体系更熟悉。Dart语言上手快热重载对调试UI帮助很大。对比下来React Native在鸿蒙上的适配进度同样不慢但我当时更急需把业务逻辑跑通Flutter是我的舒适区。当然必须承认Flutter官方对鸿蒙的支持目前更多是社区和OpenHarmony生态在推动并不是开箱即走三平台那样顺滑。这个坑我在后面第三章会重点展开。1.3 第一版功能清单与优先级项目排期上我没有搞太复杂的敏捷仪式就用一份清单管着。下面这张表是我实际用的版本标了优先级模块功能点优先级说明故事库分类列表 / 搜索P0列表用API返回即可播放器播放/暂停/切歌P0核心核心播放器倍速 / 定时关闭P1哄睡刚需播放记录自动续播P0必须做收藏收藏列表P1音频管理本地缓存P1考虑流量和离线场景系统能力熄屏后台播放P1鸿蒙长时任务家长区防误触锁P2后面补P0先保证能听P1提升体验P2看情况。这个清单让我在开发中期没有迷失方向每次想加功能就问自己一句这是不是P0不是就往后放。2. 播放器核心功能的拆解与实现2.1 小而稳的数据模型既然主要是播放应用数据模型不建议做得太复杂。我一开始参考了一些“跨平台音乐管理系统”的代码里面的表结构动辄十几张歌手、专辑、歌词、流派、评分一堆关联对故事播放应用完全是过度设计。我最终只用了两个核心模型。故事模型大致长这样class Story { final String id; final String title; final String author; final String category; final String audioUrl; final String? coverUrl; final int duration; // 秒 }播放记录模型单独存和故事解耦class PlayRecord { final String storyId; final int lastPosition; // 秒 final DateTime updatedAt; }为什么把播放记录从故事模型里拆出来因为故事本身是相对静态的内容播放记录是频繁变动的数据。两者混在一起每次更新进度都要重写整个故事对象LocalStorage的写入压力会变大而且容易出并发冲突。拆开之后故事数据可以走接口下发播放记录只存在本地数据库互不干扰。本地存储我用的是SQLite网上不少跨平台项目推荐用类似于db4s这样的开源工具去管理SQLite数据库文件。实际开发把表建好之后直接用db4s或者IDE自带的数据库面板看数据排查进度写入问题特别方便。对不熟悉SQL的读者说一句SQLite基础就三条插入、查询、更新足够覆盖播放记录场景。2.2 播放状态管理单一数据源与刷新控制这是整个项目里最需要想清楚的环节。播放器不是一个“点一下播放就完事”的组件它牵扯到播放按钮图标、进度条、上一曲下一曲、锁屏信息、后台播放状态每一个UI组件都可能关心播放状态。我最后采用的是ChangeNotifier加ValueListenableBuilder这套轻量方案。全局维护一个PlayerState对象里面放着当前故事、播放状态、当前进度、播放模式这几个字段。播放器服务通过ChangeNotifier通知所有监听者class PlayerState extends ChangeNotifier { bool isPlaying false; Story? currentStory; Duration position Duration.zero; Duration duration Duration.zero; void updatePosition(Duration p) { position p; notifyListeners(); } }这样做的核心原则是单一数据源。进度条拉到头不是直接去改进度条组件而是调用播放器服务seek然后由PlayerState广播新状态UI收到通知再刷新。凡是和播放有关的组件永远只从PlayerState读数据。这个习惯帮我避开了大量“状态散落在各页面”的坑。写到这里顺带提一下Flutter的Future和微任务队列。播放器里很多异步操作比如音频加载完成、seek结束后回调都依赖Future回调。用不好容易出现进度条跳变、按钮状态闪烁。我的经验是Future.then里的回调确实会进入微任务队列所以在这些回调里更新PlayerState没问题但不要在同一帧里连续多次notifyListeners否则会造成多余的build。最好做一下节流进度更新至少间隔300毫秒再广播一次。2.3 音频播放插件的选型与后台播放预留Flutter圈子里音频播放常用的有just_audio、audioplayers、chewie加video_player这几类。考虑到我后期要适配鸿蒙以及支持后台播放我最终选了audioplayers这个方案原因有下面几点API简单播放、暂停、seek都是同步直调。它的底层实现不像just_audio那么重接入ohos平台时改造成本相对低。社区里有针对OpenHarmony的适配分支可以拿过来作为参考。实际跑通播放功能后我又补了两个针对儿童场景的能力一个是倍速播放哄睡时经常要调到0.8倍速让故事节奏更舒缓另一个是定时关闭我用的是一个30分钟的倒计时到点自动暂停并释放音频焦点。定时器本身用Timer实现要注意在应用退到后台后Timer不一定还能准时触发所以我在鸿蒙侧申请了长时任务把定时关闭的逻辑放到原生层去兜底。后台播放这块必须提前规划。Flutter的Dart层在应用退后台后依然能维持一段时间的运行但系统为了省电随时可能冻掉进程。尤其鸿蒙系统对后台任务管理更加严格如果不申请对应的长时任务类型音频一退后台就被系统挂了。2.4 列表页到播放页的状态保持这个坑估计不少Flutter开发者都遇到过。应用底部有“故事”“收藏”“设置”三个Tab从“故事”列表进入播放详情页听完返回列表时列表居然回到了顶部。孩子本来在翻第三屏的故事返回后却看到第一屏体验很差。原因其实不复杂页面在导航栈被push后之前的列表页虽然还活着但ListView的滚动状态没有被保留。更常见的是使用系统默认的ScrollPosition页面被新页面完全遮挡后Flutter可能会回收滚动位置。我的解决方案是给ListView加上PageStorageKey并显式保留列表的ScrollControllerListView( key: PageStorageKey(story_list_$tabIndex), controller: _scrollController, itemBuilder: ... )另一个更简单的方案是使用IndexedStack把三个Tab页全部堆叠在同一个页面里切换Tab不销毁页面状态。这个方案适合Tab数量少、页面简单的场景。我最后是两者结合Tab页用IndexedStack列表内用PageStorageKey这样切Tab、进详情再返回滚动位置都不会丢。3. 鸿蒙适配环节踩到的具体坑3.1 环境搭建不是装上DevEco就完事鸿蒙适配的第一步就给了我一个下马威。我原本以为鸿蒙开发就是装个DevEco Studio、拉个Flutter SDK就行实际上要做的事比想象中多得多。首先Flutter官方SDK默认是不直接输出鸿蒙HAP包的。我需要拿到社区维护的ohos分支把它作为Flutter SDK用才能构建鸿蒙安装包。这一步就涉及到整条开发链路的切换用哪个分支、哪次提交是稳定的、和我的Dart版本是否兼容这些都需要自己试。我当时在几个版本的ohos SDK之间来回切换才找到一组比较稳定的组合。其次项目结构也变了。一个Flutter鸿蒙工程不再是纯Flutter目录它包含一个鸿蒙原生壳工程Flutter编译产物最终要嵌进这个壳里再打包成HAP。这就好比Flutter是客厅鸿蒙原生是房屋的框架客厅要装进框架才能住人。所以如果你们团队有原生开发经验的成员建议让他提前接入这个壳工程。很多纯Flutter背景的开发者一看到ArkTS那一堆配置文件就懵了。我还好以前写过安卓大概理解了壳工程的工作机制。3.2 MethodChannel与EventChannel的分工鸿蒙适配里Flutter和原生侧通信是绕不开的。热搜里也经常出现“flutter组件通信”“flutter eventchannel”说明这是大家的普遍痛点。我用一个生活化的类比解释两套通信方式MethodChannel像打电话你说一句、对面回一句适合一次性的请求比如“把系统音量调到30”“查询电池电量”EventChannel像电台广播这边持续往外发那边持续在听适合连续的数据流比如耳机插拔事件、播放进度、系统电话状态。我的项目中用EventChannel处理了两件要紧事。第一件是播放进度。Flutter侧的Dart代码通过EventChannel订阅原生音频播放器每500毫秒发送一次的进度消息这样即使播放核心逻辑放在原生层UI进度条也能实时更新。第二件是来电处理。鸿蒙平板或手机上如果有电话进来原生侧会把电话状态通过EventChannel推给Flutter让播放器自动暂停。这个交互用MethodChannel做就不合适因为来电事件是不定时的原生要主动“告诉”Flutter而不是Flutter反复“问”原生。代码层面Dart端大致长这样static const _eventChannel EventChannel(project/player_event); _eventChannel.receiveBroadcastStream().listen((event) { // event[type] callStateChanged 就暂停播放 });原生侧则在对应生命周期里通过同一个channel名字推送消息。两边channel名必须完全一致不然数据怎么都传不到Dart层。我当时调试半天最后发现是原生侧channel名后面多了一个斜杠。3.3 底部导航栏和安全区的鸿蒙长相这个项目里底部导航栏做起来比预想中费时间。原因是鸿蒙设备和普通安卓手机不一样鸿蒙平板底部有手势条很多设备还有虚拟导航条如果设计时不做安全区适配导航栏就会被手势条挡住或者按钮贴近屏幕底部孩子一碰就触发返回。我在Flutter侧没有直接用系统自带的NavigationBar而是自己画了一个底部栏然后用MediaQuery.paddingOf来动态调整底部内边距final bottomPadding MediaQuery.paddingOf(context).bottom;在鸿蒙平板上这个值会是手势条高度在带虚拟导航条的老设备上它会有额外像素。把这个padding加到自己的底部栏容器上就能保证任何设备都不会被遮挡。另外要提一下的是鸿蒙系统的横屏切换逻辑和安卓有些差异尤其是平板旋转时可能会触发窗口尺寸变化。我当时遇到一个问题横屏旋转后底部导航栏按钮还在原地但页面内容却旋转了导致按钮和内容脱节。排查下来问题出在我锁定了某些页面的方向但又没有同步更新安全区。最后统一了全应用的orientation配置才解决这类问题。3.4 被绕过去的PlatformView热搜词里有“flutter platformview”在鸿蒙上这确实是个大坑。PlatformView的意思是让Flutter页面里嵌入一个原生视图比如嵌入式地图、原生WebView。但鸿蒙的PlatformView适配还不算稳定很多原生组件在Flutter上层会显示异常要么闪黑块要么触摸事件不穿透。我在这个项目里差点要用PlatformView嵌入一个Webview加载故事文本后来权衡了一下干脆把故事文本全部用Flutter自带组件渲染不做WebView。这样虽然少了一些富文本能力但换来了稳定性。给读者的建议是在鸿蒙适配阶段能不用PlatformView就不用。如果非用不可至少准备两套展示方案并且一定要在真机上测试触摸事件模拟器上跑出来的效果和真机差别巨大。4. 免费内容从哪来公版故事与本地优先策略4.1 公版内容怎么核实项目叫“免费少儿故事播放”那么最核心的问题就是故事音频从哪来市面上一堆收费内容的音频用了就是版权风险。我采取的方案是主攻公版故事和自制内容。所谓公版故事就是著作权保护期已经届满、进入公共领域的作品。像安徒生童话里的《丑小鸭》《卖火柴的小女孩》格林童话里的《小红帽》《三只小猪》这些都属于公版作品。中国古代寓言《乌鸦喝水》《狐假虎威》这类民间故事同样是比较安全的内容来源。注意“公版”不代表“随便用”。文字公版不等于某个主播朗读的音频公版。你找来的音频如果是有声书平台录制的哪怕文本公版录音本身也有著作权。我最终采用的方式是自己录制。用一套简单的录音设备在家把故事朗读成音频加上后期简单降噪效果足够孩子听了。如果不想自己录音还有一个合法路径使用明确标注“公有领域”或“CC0协议”的音效库、有声书但一定要仔细看协议条款尤其是能否商用、能否修改避免版权纠纷。4.2 音频资源组织与缓存设计故事音频多了之后资源的组织方式也需要提前设计。我按“故事ID/音频版本”的目录结构存本地文件audio/ story_10001/ v1.mp3 story_10002/ v1.mp3这样每次更新音频版本不用改变数据库里的URL只需替换文件。我用Flutter的本地数据库存故事信息音频URL指向相对路径播放前检查本地文件是否存在存在直接放不存在再走网络下载。缓存策略上我采用了“播放即缓存”的思路。用户播放某条故事时自动把音频文件下载到本地。这样同一个故事第二次播放就走本地文件不消耗流量也比较适合车载、地铁等弱网环境。下载时我用了一个简单的队列同一时间只允许两个下载任务并发避免老设备卡顿。4.3 不登录、少联网的本地优先原则市面上一堆儿童App要求手机号注册、实名认证在我看来对孩子和家长都是负担而且隐私风险不小。我的处理是完全不做登录。第一版不联网也能完整使用。把故事列表预置在本地数据库里音频文件用安装包内置一部分剩下通过网络增量下载。这样做有几个好处用户打开即用没有注册摩擦。没有账号体系自然就不需要收集手机号。离线场景完全可用睡前在卧室断网也能放。有读者可能会问不登录怎么做云同步答案是第一版不做云同步。播放记录只在本地换设备就重新开始。对绝大多数家庭用户来说这完全不是高频需求但可以避免一大堆账号、安全、合规问题。等功能稳定后再考虑增加家庭共享同步也不迟。5. 少儿场景的体验与安全细节5.1 防误触与家长入口孩子用触屏设备最大的问题是误触。有一次我孩子在播放页面一顿乱按不但切了故事还误触了列表页的删除按钮。为此我加了一个“儿童锁”功能播放页开启之后除了暂停、上一首、下一首这三个大按钮其他操作全部锁定。长按空白区域3秒才进入解锁状态解锁时需要做一个简单的加法题比如“3加5等于几”防止孩子自己解开。这个功能的实现其实很简单就是用一个Boolean状态记录当前是否锁住锁住时把复杂按钮的点击事件直接拦截。但就是这个小功能家里老人反馈特别好说再也不怕孩子乱按了。强烈建议所有少儿类应用都考虑这种防误触机制。5.2 熄屏播放与来电处理哄睡场景下屏幕关着但故事要继续放。这背后涉及的不只是播放器本身还有系统对后台任务的限制。在鸿蒙上我需要申请后台长时任务类型是audio申请成功后才允许音频在后台持续播放并且会在通知栏显示一个常驻通知用户可以从通知栏直接暂停或播放。协调上下两个层面花了些时间。Flutter层的音频服务负责播放原生壳工程负责申请长时任务和处理音频焦点。通过EventChannel把焦点变化告诉Flutter比如来电话了就先暂停。实际用下来熄屏播放的耗电量控制得也还行。一个晚上放三个小时故事耗电大约8%左右比视频播放低了一个数量级。如果想进一步省电可以限制后台播放时的音量动态处理减少音频处理链路。5.3 到底采集了哪些数据做少儿应用隐私问题再怎么强调都不过分。我的原则是能本地处理的就绝不传服务器。整个应用不采集儿童头像、姓名、生日、地理位置不使用行为分析SDK不做个性化广告。Flutter应用默认有FlutterError和日志上报机制这些日志可能包含设备型号和系统版本。为了最大限度降低隐私风险我在正式包里关闭了所有日志上传只在Debug包保留日志输出。同时提交应用市场时把隐私政策写得尽量直白一句话概括就是“本应用不收集您的任何个人信息”。这一点可能和某些商业产品“巴不得把用户数据拿到手”的思维相反。但在少儿应用领域克制比激进更重要。少收集数据不仅合规压力小用户信任度也高。6. 构建打包与后续维护的一些个人经验6.1 从APK思维切换到HAP思维如果你之前一直做安卓开发第一次接触鸿蒙打包时会非常别扭。安卓出的是APK鸿蒙出的是HAP两者的签名、包名、权限声明、应用市场审核目录都不同。Flutter鸿蒙工程里构建命令会把Dart代码编成so库然后把so库连同资源打包进HAP。打包流程上我发现最容易出错的是签名配置。鸿蒙和安卓一样签名证书不对真机装不上。而且鸿蒙的调试证书、发布证书和Profile绑定得很死有一次我图方便用调试证书打了一个发布包结果手机上安装时一直报“证书不匹配”折腾了一整天才定位到问题。6.2 那次莫名的资源断言错误说起来很邪门有次打包HAP时构建系统抛了一个类似“could not close resource”的断言错误。当时第一反应是代码哪里写坏了但翻遍代码也没找到问题后来才意识到根因在资源目录上。原因是我把本地音频文件直接放进了assets目录而且没有做显式声明。Flutter在打包时会对assets做资源文件处理但鸿蒙的构建链路对资源和原生so库的加载顺序更敏感。某些路径下的资源文件在打包时被重复处理触发了“关闭资源失败”的断言。解决方式是给资源文件加一个独立的子目录并确保assets声明里不包含重复路径。另外不要把所有音频一股脑塞进安装包安装包体积太大也会让构建时间急剧变长。第一版我内置了5个故事做种子内容其余全部走网络增量下载。6.3 迭代计划与方向第一版上线后我自己在家里用了两周孩子反馈不错但真正冲着我来的需求是“多来点好听的故事”。这个理由让我觉得这个项目值得继续做。接下来的迭代方向我想了三个一是完善离线下载管理增加按系列批量缓存二是加入家长数据看板不采集隐私的前提下用本地统计展示孩子最近喜欢听哪类故事三是把UI动画做得更柔和配合睡眠氛围。至于是否要做鸿蒙原生能力更深的体验比如桌面卡片快捷播放、控制中心集成我倾向于等Flutter的鸿蒙生态再成熟一点再动手。这个项目最有价值的经验其实就一句话先把播放这件事真正做到无感再考虑其他花活。少儿应用用户要的从来不是功能多而是不打扰、能安心听。如果你也想给自家孩子写一个干净的故事应用选Flutter加鸿蒙这条路只要先把播放器核心和后台播放这两个环节想透了剩下的都是水到渠成的事。
返回列表