做过一阵子移动端开发的人应该都有同感:UI 写完、数据流跑通,真正决定一个产品能不能留下用户的,往往是那些不起眼的模块。手语学习 App 就是典型——词库再全、演示动画再流畅,用户学完一遍之后没有任何记录、没有反馈、没有连续性的激励,这个 App 很快就会沦为手机里一个占内存的图标。所以这次做手语学习项目的时候,我把练习记录放到了和词库内容几乎同等的优先级上,并且把 Flutter for OpenHarmony 作为整套技术路线来落地。
这个项目做下来,最大的体会是:练习记录不是一个"加张表、写个列表"的小功能,它本质上是学习类产品的隐形骨架。本文会从选题动机、环境搭建、词库设计一直拆到练习记录模块的表结构、存储选型和统计实现,最后再复盘几个真机运行时的坑。如果你正准备在 OpenHarmony 上跑 Flutter,或者正在设计练习、打卡、错题回顾这类功能,这篇应该能给你一些可以直接抄作业的参考。
1. 这个项目的起点:手语学习需要"记录"而不是"浏览"
1.1 用户需求与核心场景
手语学习和普通语言学习最大的不同在于:普通语言有文字、有发音,碎片时间扫一眼就能获得输入;手语是视觉语言,要看手部动作、面部表情和口型的配合,学习本身对"整块时间"的要求更高。很多初学者最初是被新鲜感驱动的,翻了几十个词条之后心里其实没有锚点——今天学了哪几个词?哪些词总是搞混?上周练过的动作还能不能比划出来?没有记录的学习,基本等于没学。
我在设计阶段把目标用户定义成两类。第一类是日常有沟通需求的听障人士家属、朋友或同事,他们不需要考级,只需要快速掌握高频生活词汇,比如问候、就餐、出行、就医这些场景。第二类是自己有听力障碍、想系统学习手语表达的群体,他们更需要练习的连续性和成就感。两类人的共同点是:都希望用最短的路径进入"高频词 + 互动练习"的循环,而不是被动地在一个巨大的词库里翻看。
所以整个 App 的核心场景被切成三段:浏览学习、主动练习、回顾统计。浏览学习解决"看到并理解",主动练习解决"记住并能比划出来",回顾统计解决"知道自己到底练了多久、错在哪里、有没有进步"。三段缺一段,这个 App 就只是一个词典,而不是学习工具。
1.2 技术路线选择的理由
技术选型上,当时摆在面前的有三条路线:OpenHarmony 原生 ArkUI、Flutter for OpenHarmony、纯 Web 套壳。原生 ArkUI 在 OpenHarmony 平台上最稳,社区文档也相对完整,但有一个现实问题:这个项目后期大概率要同时覆盖其他移动平台,至少在手机端和平板端都要跑起来。如果直接上原生,UI 要写两套,用户数据层代码也基本要重写。纯 Web 套壳开发最快,但视频演示、手势动画、本地记录这类偏原生能力的交互体验会明显打折,而且离线能力弱,对听障用户的使用场景不友好。
Flutter for OpenHarmony 是 OpenHarmony 社区维护的 Flutter 适配分支,它的核心价值在于:Dart 代码和 Widget 层基本不绑定具体平台,OpenHarmony 只是其中一个渲染后端。这意味着我可以在标准 Flutter 工程里正常写业务逻辑,编译时切到适配分支构建出 OpenHarmony 应用包,后续如果要在其他平台复用,业务层几乎不用动。当然,适配分支不是官方主线,更新节奏比主 Flutter 慢,版本锁定的要求很严格。这个代价我完全能接受,因为对个人项目来说,可控的、稳定的技术栈比永远最新的技术栈更值钱。
1.3 MVP 功能边界
为了避免项目失控,第一版的功能范围我卡得非常紧,核心列表如下:
- 手语词库分类浏览:按生活场景分类,支持关键词搜索
- 词条详情:短语演示视频、文字释义、动作要点提示
- 学习状态标记:未学 / 学习中 / 已掌握
- 练习模式:从当前分类出题,用户自我判断对错
- 练习记录:每次练习的会话、明细、正确率、连续天数
- 统计页:今日进度、最近七日趋势、历史练习列表
视频上传和云同步在第一版里坚决不做。原因很直接:练习记录这种用户私有数据,第一版用本地存储就够了,只要数据结构设计成一个可替换的 repository 接口,以后上云同步只是换一个实现的问题;视频资源如果一开始就想着搞后台管理,内容生产流程会拖垮整个项目的节奏。做个人项目,范围控制比功能堆叠重要得多。
2. Flutter for OpenHarmony 环境搭建:版本、分支与第一个能跑的 Demo
2.1 工具链与版本组合
环境搭建是这类项目的第一个门槛,因为 Flutter for OpenHarmony 不是装上官方 Flutter 就能直接用的。央视三套?不相关。我当时锁定的工具链组合大致如下:
| 组件 | 选择 | 说明 |
|---|---|---|
| Flutter 分支 | OpenHarmony 社区维护的 flutter_flutter 适配分支 | 不是官方 main 分支,版本必须锁死 |
| OpenHarmony SDK | 对应 LTS 版本 | 和适配分支有版本对应关系 |
| 构建工具 | hvigor 构建工具 | 需要独立安装,类似 Android 世界的 Gradle |
| Dart SDK | 跟随 Flutter 分支自带 | 不需要单独装 |
| 本地属性 | 手工配置 SDK 路径 | local.properties 里写 sdk.dir |
这里最容易被忽略的是版本对齐。Flutter 适配分支会对应 OpenHarmony SDK 的某个版本范围,两个版本对不上,编译阶段会报一堆非常绕的错误。我当时吃过这个亏,后来养成一个习惯:把版本组合写进项目 README 的最前面,固定住,避免隔几个月回来继续开发时完全想不起当时用的是哪个版本。
2.2 工程初始化:两条路线
初始化工程有两条路线。路线 A:先用标准 Flutter 建工程,再切到适配分支构建。执行flutter create hand_sign_app,把 lib 目录写好,然后用适配分支构建链生成 OpenHarmony 平台目录。路线 B:直接从社区提供的 OpenHarmony Flutter 样例工程拷贝骨架,清掉样例里的业务代码再开始写。路线 B 能快速看到效果,但样例工程经常带了一堆自定义依赖,清理不干净会一直报错,而且你不太清楚哪些代码是可以删的。
我实际选用的是路线 A,但额外做了一步:先用一个空工程把 OpenHarmony 构建链完整跑通,确认环境没问题,再开始写业务代码。这一步看起来浪费一两个小时,实际上非常值得。如果上来直接写业务代码,写完一大片再构建,一旦环境有问题,你根本分不清报错到底来自环境还是来自自己的代码,排查难度翻倍。
2.3 第一次构建的坑
第一次构建踩的坑值得单独列一下。第一个是 OpenHarmony SDK 路径没被构建脚本探测到。适配分支的构建脚本虽然会自动探测,但探测失败时需要手工在工程的 local.properties 里写sdk.dir,路径里不要带中文和空格,否则解析阶段就会出现很诡异的字符错误。
第二个是 JDK 版本。OpenHarmony 的构建链对 JDK 版本很敏感,我一开始用了较新的大版本,结果构建时直接提示某个 Task 类不兼容。换成构建链要求的对应版本后,问题立刻消失。所以遇到构建工具类报错,先排查 JDK,这是成本最低的一步。
第三个是签名配置。构建 OpenHarmony 应用包需要配置签名文件,调试阶段如果不签名,装到开发板或模拟器上会被拒绝安装。当时我用的是调试证书,但证书和 profile 文件路径都必须填到构建配置里才能通过。这三个问题单个都不难,麻烦的是报错信息很绕,网上能查到的资料也少。我的经验是:遇到看不懂的构建错误,先怀疑版本组合,再怀疑配置路径,最后才去查业务代码。环境问题首轮开发至少占了三成时间,但排干净之后,后面写代码就非常顺。
3. 词库与学习流程:数据模型先行
3.1 词条数据模型设计
手语学习 App 里最核心的数据是"词条"。我把词条模型设计成可扩展的,字段如下:
- id:语义化字符串,比如
greet_hello,而不是自增数字,方便跨分类引用 - categoryId:所属分类
- title:中文名称
- subtitle:释义说明
- mediaUrl:演示视频地址或图片地址
- tips:动作要点,比如"双手掌心相对,向上摆动"
- difficulty:1 到 3 级的难度
- status:学习状态,未学 / 学习中 / 已掌握
有个设计细节很关键:status 是用户维度的数据,而不是词库本身的属性。同一个词条,不同用户的学习进度完全不同。如果把 status 直接放进词条表,后续同步、筛选、统计都会变得非常别扭。所以词库本身只保留静态内容,用户相关状态全部放在独立的表里,通过词条 id 关联。
3.2 分类浏览与学习详情页
首页分类浏览用标准的 ListView 实现。数据来源第一版直接写死在一个本地 JSON 文件里,结构就是"分类 -> 词条数组"。首版不接网络拉词库是因为手语学习的使用场景里离线比在线重要得多,而且词库资源文件本身不大,打进应用包里完全没问题。听障用户在户外、地铁这些网络不稳定场景下打开 App,离线可用是刚需。
学习详情页是整个 App 的视觉重点。手语是动作语言,单词页最重要的部分是演示。我以演示视频为主,图片为辅。视频比静态图能更完整地表达手语的连贯性,但视频文件体积和播放器兼容性都需要处理。这里我留了一个降级策略:如果某个词条的视频不可用,页面用三到五张关键帧图片拼成一个简单动画。虽然不如视频精准,但能保证基础学习功能不挂。这种"保底方案"在真实项目里非常实用,不要为了追求完美体验把所有功能都绑死在单一资源上。
3.3 学习状态与收藏逻辑
学习状态我定义为三种:未学、学习中、已掌握。用户在详情页点一下"标记为已掌握",这个词条的 status 就会更新到本地数据库。练习模式会优先抽取"学习中"状态的词条,因为这些词正处在遗忘边缘,是练习价值最高的部分。
收藏功能和学习状态是两套数据。我单独建了一张 favorites 表,绑定词条 id,避免每次查询都要拼一堆条件。首版收藏只是在详情页放一个心形按钮,没有做单独的收藏夹页面。但表已经建好了,后面如果要做"我的收藏夹分组学习",就是非常自然的功能延伸,不需要动现有结构。
4. 练习记录模块:从表设计到统计展示的完整实现
4.1 要记录哪些字段
设计练习记录模块时,我反复问自己三个问题:用户什么时候开始练习?练习过程中发生了什么?练习结束之后要看到什么?这三个问题直接映射成数据字段。
一次练习是一个会话 session,会话有开始时间和结束时间;会话过程中每个词条的答题情况是一条明细 item;会话结束后要能算出正确率、总题数、用时和连续天数。字段设计如下:
会话主表:
- id:主键
- started_at:开始时间,毫秒时间戳
- finished_at:结束时间戳
- mode:练习模式,比如普通练习、错题重练
- total_count:总题数
- correct_count:正确数
明细表:
- id:主键
- session_id:关联会话主表
- word_id:词条 id
- is_correct:是否正确
- duration_ms:该词条耗时
- created_at:记录时间
我没有在会话主表里冗余存一个"正确率"字段,因为正确率随时可以由 total_count 和 correct_count 算出来。冗余字段最大的问题是更新时会话数据如果没写全,页面展示就会出现两个数对不上的尴尬情况。能算出来的指标就不要存。
4.2 存储方案选型与表结构
练习记录的数据量会随使用时间持续增长,而且统计页需要按日期做聚合查询,所以我第一反应就是关系型数据库,而不是 shared_preferences 之类的键值存储。在 OpenHarmony 环境下,我原本计划直接用 Flutter 生态里常用的 sqflite,但实测发现底层 sqlite3 的适配在这个分支上还不稳定。最后做了一个折中:表结构仍然按 SQL 设计,数据库访问全部封装在一个 repository 接口后面。底层优先用本地 SQLite 兼容方案;如果目标设备上插件跑不通,就退回 JSON 文件存储,只保证 repository 接口不变。
这个 repository 抽象是这次项目里最值得的一步。因为存储方案的调整没有影响到任何页面代码,页面只依赖接口,不依赖具体实现。SQLite 建表语句如下:
CREATE TABLE practice_sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, started_at INTEGER NOT NULL, finished_at INTEGER NOT NULL, mode TEXT NOT NULL DEFAULT 'normal', total_count INTEGER NOT NULL DEFAULT 0, correct_count INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE practice_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL, word_id TEXT NOT NULL, is_correct INTEGER NOT NULL, duration_ms INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL ); CREATE INDEX idx_practice_items_session ON practice_items(session_id); CREATE INDEX idx_practice_sessions_time ON practice_sessions(started_at);索引不是可选项。统计页经常做按时间段的聚合查询,如果没有索引,记录多了以后查询速度下降非常明显。首版可能只有几百条数据,用户感受不到差异;但一旦用户真的坚持每天练习,几个月积累下来就是上千条记录,这时候有没有索引完全是两种体验。
4.3 练习交互闭环
练习模块的交互我把它设计成"回忆—确认—记录"三步闭环。第一步,页面展示一个词条的文字释义和动作提示,用户在心里回忆手语动作;第二步,用户点击"显示动作"看演示视频,或者自己比划一遍;第三步,用户点击"答对"或"答错",系统记录该词条是否正确,以及从题目出现到用户做出判断的时长,然后进入下一个词条。
这个流程交互上非常简单,但对应的是主动回忆的学习机制。如果直接放视频让用户看,效果和浏览学习没有区别,练习就失去了意义。耗时字段也有大用:答对但耗时很长的词条说明还不够熟练,以后要做"薄弱词条"列表,duration_ms 就是最重要的判据。
核心状态管理我用了 Riverpod。相比手写 setState,Riverpod 最大的好处是练习页、结果页、统计页能共享同一个状态源。练习会话结束之后,结果页可以直接从同一个状态源拿数据,不需要通过路由参数或者全局单例传递。核心控制器的大致逻辑如下:
class PracticeSessionController { final PracticeRepository _repository; final List<WordItem> _roundWords; int _index = 0; int _correctCount = 0; int _totalCount = 0; int _roundStartAt = 0; void submitAnswer(bool isCorrect, int durationMs) { if (isCorrect) _correctCount++; _totalCount++; // 实时记录明细,避免中途退出丢数据 _repository.logAnswer( _roundWords[_index].id, isCorrect, durationMs, ); _index++; } Future<PracticeSummary> finish() async { final summary = PracticeSummary( totalCount: _totalCount, correctCount: _correctCount, duration: DateTime.now().millisecondsSinceEpoch - _roundStartAt, ); return _repository.saveSession(summary); } }这里有一个很重要的细节:明细是实时插入的,不是等一轮练习结束再批量写入。移动端场景里,用户很可能在练习中切走,甚至应用被系统杀掉。如果等到结束才写库,中途完成的答题记录就全丢光了。实时插入虽然多几次数据库写操作,但对几十题一轮的体量来说完全没有压力,换来的是数据安全。
4.4 连续天数与正确率统计
统计页最有价值的两个指标是连续练习天数和正确率走势。连续天数就是常见的打卡统计,算法不复杂,但有一个非常容易踩坑的地方:日期必须按本地时区算,不能直接用 UTC 时间戳比较。
举个例子,用户在北京时间的晚上十一点练习,换算成 UTC 时间可能是同一天下午三点,也可能因为时区偏移变成前一天。如果直接拿时间戳换算出 UTC 日期来判断"今天是否已经练习",一次正常的晚间练习很可能被归到错误的日期里,连续天数直接就断了。所以要先统一转成本地日期再比较,代码逻辑如下:
int calculateStreak(List<DateTime> practiceDates, DateTime today) { final dateSet = practiceDates .map((d) => DateTime(d.year, d.month, d.day)) .toSet(); var streak = 0; var cursor = DateTime(today.year, today.month, today.day); while (dateSet.contains(cursor)) { streak++; cursor = DateTime(cursor.year, cursor.month, cursor.day - 1); } return streak; }正确率走势的核心也在日期处理上。把每天的 total_count 和 correct_count 按天分组聚合,就能画出一条七日趋势。这个图表我是用自绘画布组件实现的,没有引入第三方图表库。因为首版统计维度就两个,引入图表库反而增加包体积和维护成本,不划算。
4.5 练习历史与今日进度展示
练习历史列表页按时间倒序展示所有会话记录,每条记录显示日期、练习模式、题数和正确率。点击某一条可以展开当轮的明细,看到每个词条当时的答题结果。这个列表把整个"记录"形成闭环:用户先浏览学习,再主动练习,最后回到统计页回顾。
今日进度我用一个简单的进度环展示在统计页顶部。数据源来自当天所有会话的累计值。这里需要注意,一个用户当天可能练了好几轮,统计今日数据时不能只取最后一条会话,必须把 started_at 落在当天范围内所有会话都聚合起来。历史记录列表则另外负责展示每一次会话的独立数据,二者维度不同。
5. 真机运行实测与一次完整的踩坑复盘
5.1 在 OpenHarmony 设备上跑通的过程
业务代码写完之后,真正拿到 OpenHarmony 设备上运行是另一回事。我的节奏是先跑空工程做能力验证,再逐步把业务模块加回来。第一次在设备上看到词条列表正常滑动、演示视频能播放、练习记录能写进本地数据库,确实比看到 UI 一蹴而就还有成就感,因为这一步验证的是整条技术链路的可行性。
但运行实测也暴露了一个问题:Flutter for OpenHarmony 分支在渲染视频时的兼容性不够稳定。标准 video_player 插件在这个分支下如果没有经过适配,播放会出现黑屏或者首帧延迟明显。我的处理思路是先查清楚插件适配范围,把不强依赖视频内核的功能优先做扎实。这正好印证了方案决策阶段的判断——练习记录模块本身不依赖视频,即使视频组件临时出问题,记录和统计功能不会跟着崩。
5.2 练习记录模块的三个边界问题
真正容易出问题的全在模块边界上。第一个是应用在练习中途被系统回收后的数据一致性。因为明细是实时写入的,但如果会话主记录要等到 finish 才创建,就会出现明细存在、却找不到对应 session 的孤儿数据。我的解决方式是,在一轮练习开始时就主动创建一条 session 记录,状态标记为进行中,后续每条明细都挂在同一个 session_id 下面,finish 时再回填 total_count、correct_count 和结束时间。这样即使中途退出,至少能从明细数据里恢复出半个会话,不至于彻底丢。
第二个边界是数据库结构升级。首版上线之后如果要加字段,比如给 practice_items 表增加一个"是否由错题重练产生"的标记,直接在旧库上读新字段一定会崩。所以 repository 层必须支持数据库版本号,每次结构变更把 version 加一,在 onUpgrade 回调里执行增量迁移语句。为这个项目写一个完整的迁移逻辑,比临时改表结构安全得多。
第三个边界是时区问题,就是前面连续天数里提到的情况。这个坑在开发自测时特别容易被忽略,因为测试都发生在本地时区,代码在本地永远是正确的,可一旦用户跨时区使用,问题马上暴露。所以记录模块所有时间字段统一存毫秒时间戳,展示层再转本地时间格式。千万不要存 "2025-01-01 10:00" 这种字符串,字符串格式在排序、比较、聚合时都会成为隐患。
5.3 对后续开发有用的三个习惯
这个项目做完,我总结出三个值得固化的开发习惯。
第一,数据访问一律走 repository 层,不允许在页面组件里直接写 SQL 或文件操作。哪怕第一版只有本地存储,这个抽象也能保证后续添加网络同步、换数据库实现时,上层页面代码完全不动。
第二,所有时间字段统一存毫秒时间戳。存字符串格式的日期时间相当于给自己埋雷,排序、跨时区、聚合查询每一样都会出问题。
第三,每次构建前固定检查版本组合。Flutter for OpenHarmony 这类适配分支对版本太敏感,升级任何一个组件之前都必须先确认兼容关系,否则你会发现一个很小的依赖升级会引发整个工程构建失败,而且报错信息还特别绕。
最后再分享一点实际开发完的体会。练习记录模块看起来是"加个表、写个列表"的小活,但它其实是整个学习类 App 的隐形骨架。词库内容决定用户最初是否打开这个 App,而记录和反馈决定用户能不能留下来。尤其是手语学习这种需要长期坚持的场景,一次练习后的正确率、一条连续打卡记录,比任何精心设计的启动页都更能留住人。如果后续继续做这个方向,我会优先把"错题重练"和"薄弱词条推荐"补上,让已经积累下来的练习数据真正开始指导用户的学习路径,而不是只停留在展示层面。