
1. 为什么先做“编辑家人”而不是相册瀑布流家里照片堆积了几年之后我一直想做一个自己人用的家庭相册。最开始我也跟大多数人一样打开设计稿就先做相册时间轴、九宫格、照片放大预览这些看起来最亮眼的东西结果做到一半发现一个问题如果照片里没有一个人物维度那相册就只是一堆按时间排列的图片根本撑不起家庭这个场景。想让妈妈迅速找到宝宝去年夏天的照片想按家庭成员的维度回看某个人在某段时间的样子靠时间和地点是做不到的。所以我调转顺序先把成员管理做扎实。而这个功能里编辑家人又是最核心的一环新建成员、编辑已有成员信息、修改头像和备注这一整套流程会覆盖表单、本地存储、图片选择、页面回传这些基础能力。把这些跑通后面所有跟人相关的功能都会变得很顺。这篇实战记录就是围绕编辑家人这个功能展开的用的是 Flutter for OpenHarmony 这条技术路线目标是给同样打算做跨端家庭类应用的朋友一个可参考的实现路径。我这次没有选用纯 OpenHarmony 原生开发原因是手里本身就有不少 Flutter 的业务代码沉淀而且 Flutter 在 UI 表达和状态管理上更顺手。OpenHarmony 的生态目前还在快速完善中Flutter 的适配分支已经能把 Hello World 跑起来但真正做业务功能时会有不少差异化问题这篇文章会把这些坑一个一个讲清楚。读者可以按自己的基础来选择阅读方式。如果你刚开始接触 Flutter 和 OpenHarmony 的配合建议从头看起前面两章把环境和工程讲得比较细如果你已经能跑通 Demo可以直接跳到第三章之后的内容那里有数据建模、表单实现以及一堆实测才会遇到的坑。2. 工程落地让 Flutter 跑在 OpenHarmony 设备上2.1 环境准备里最容易忽略的版本对齐问题跑通 Flutter for OpenHarmony 的第一步不是写代码而是把版本环境对齐。我一开始犯过一个很低级的错误电脑上装的是官方稳定版 Flutter然后直接把适配分支拉下来两个 SDK 放在同一个 PATH 里结果flutter doctor一顿乱报工程也建得四不像。后来我学乖了OpenHarmony 的 Flutter 适配分支单独放一个目录不用官方 Flutter SDK 根目录去覆盖。目录结构大概类似这样~/dev/ ├── flutter_ohos # 适配分支的 SDK ├── ohos_sdk # OpenHarmony SDK └── work/home_album # 项目目录环境准备的核心点有三个。第一适配分支通常有对应的 Dart SDK 和 Flutter 版本要求不能拿官方最新版本直接混用否则会碰到奇怪的编译错误第二OpenHarmony SDK 要安装对应 API 版本的 toolchain模拟器和真机需要的 SDK 版本也要匹配第三flutter doctor -v要能正确识别到 OpenHarmony 平台识别不到时先查环境变量很多时候是路径没有配进PATH。我用的是社区维护的一个适配分支在项目文档里会明确标注它基于哪个 Flutter 版本。我照着文档锁定版本号把路径配置完之后flutter doctor终于能正常列出设备。如果你也在折腾这一步记住一个原则凡是版本号对不上的问题不要靠运气去试直接回到适配分支的发布说明里找映射关系。2.2 创建一个带 ohos 平台的工程适配分支的flutter create命令会在工程里生成一个ohos目录这个目录对应 OpenHarmony 的原生工程结构。我用的是这样的初始化命令flutter create --org com.example --project-name home_album .创建成功后工程里会同时存在lib、android、ohos等目录。如果你只想保留 OpenHarmony 平台可以把android目录删掉减少后续混用带来的干扰。如果创建出来的工程没有ohos目录也不用慌。此时需要手动补一个原生工程模板把适配分支仓库里的示例工程中的ohos目录拷贝到项目中再检查ohos目录下的构建配置和包名是否一致。我第一次补模板时漏改了 bundleName结果安装阶段直接失败报错信息指向应用标识不匹配。所以创建工程之后第一件事就是检查包名和工程标识是否统一。2.3 跑通第一个 HAP 包的注意事项在 OpenHarmony 上Flutter 的构建产物不再是 APK而是 HAP 包。构建命令大概率是flutter build hap如果不行就找适配分支里带的自定义构建脚本。我第一次执行构建时遇到的是签名问题OpenHarmony 应用安装需要签名配置模拟器调试可以先用自动签名真机调试建议准备独立的签名证书。跑通第一个 HAP 的过程比我预想的要曲折一些。构建完安装到模拟器后第一屏是空白的logcat 里没有任何 Flutter 相关的输出。排查之后发现是原生侧入口没有调用 Flutter 的启动接口模板工程里只保留了一个默认的 PageAbility需要把入口指向 Flutter 容器。这一步在适配分支的示例代码里通常有现成的写法照抄过来再重新构建就好。跑通之后你在 OpenHarmony 上看到的 Flutter 页面本质上是 Flutter 渲染层接管了一块原生窗口。理解这一点很重要因为后面遇到键盘弹出、页面跳转、图片选择这些问题很多都跟两层引擎的协作方式有关。3. 家人数据建模先想清楚要编辑什么3.1 成员字段设计少而够用家庭相册的成员信息不需要像社交产品那样复杂字段太多反而让长辈不会用。我设计的FamilyMember模型包含这些字段字段类型说明idString唯一标识新建时生成nameString姓名必填relationString关系标签如爸爸/妈妈/宝宝birthdayDateTime?生日可选avatarPathString?头像文件路径可选avatarColorValueString头像底色没有头像时用于文字头像remarkString备注可选没有单独存年龄因为年龄会随时间变存生日就够了展示年龄时动态计算。头像也没有直接用网络 URL而是存本地文件路径原因是家庭成员的照片通常涉及隐私不适合默认上云。这里有一个容易被忽视的点头像颜色。家里的老人和孩子不一定愿意拍照或者一时找不到合适的照片所以我在编辑页提供了文字头像的兜底方案取姓名最后一个字配上一个从成员列表底色集合里自动分配的颜色。这个设计在实测中非常实用至少不用为了一个头像卡住整个流程。3.2 为什么选择 JSON 文件存储而不是上数据库家人列表的数据量非常小一个家庭撑死二三十人这种量级根本不需要引入数据库。我选的是本地 JSON 文件存储把成员列表序列化成一个数组读写都走文件 IO。选择这种方案还有另一个原因OpenHarmony 上的 Flutter 插件生态还不算完整sqflite 这类数据库插件是否能在当前适配分支上稳定运行是个未知数。与其花时间排查数据库插件的兼容性不如用一个纯 Dart 就能搞定的方案把不可控因素降到最低。文件存储并不完美它的缺点是没有事务和高效查询但在这个场景下完全够用。等到后面需要按成员维度关联照片索引时再考虑引入数据库也不迟。现在写的模型只要能稳定序列化和反序列化就达到了这次的目标。3.3 模型代码与序列化实现模型类我用了手写toJson/fromJson原因是不想为了一个简单模型引入 json_serializable 的构建链路。手写代码看起来多一点但胜在清晰可控修改字段时不会因为忘记运行代码生成工具而出错。class FamilyMember { final String id; String name; String relation; DateTime? birthday; String? avatarPath; String avatarColorValue; String? remark; FamilyMember({ required this.id, required this.name, this.relation 家人, this.birthday, this.avatarPath, this.avatarColorValue #4A90D9, this.remark, }); factory FamilyMember.fromJson(MapString, dynamic json) { return FamilyMember( id: json[id] as String, name: json[name] as String, relation: json[relation] as String? ?? 家人, birthday: json[birthday] ! null ? DateTime.tryParse(json[birthday] as String) : null, avatarPath: json[avatarPath] as String?, avatarColorValue: json[avatarColorValue] as String? ?? #4A90D9, remark: json[remark] as String?, ); } MapString, dynamic toJson() { return { id: id, name: name, relation: relation, birthday: birthday?.toIso8601String(), avatarPath: avatarPath, avatarColorValue: avatarColorValue, remark: remark, }; } }存储和读取的逻辑单独放在一个MemberRepository里统一管理文件路径和读写动作。路径用的是getApplicationDocumentsDirectory()应用起来后会在body/目录下的 JSON 文件中存放成员数据。FutureListFamilyMember loadMembers() async { final dir await getApplicationDocumentsDirectory(); final file File(${dir.path}/family_members.json); if (!file.existsSync()) return []; final content await file.readAsString(); final list jsonDecode(content) as Listdynamic; return list .map((item) FamilyMember.fromJson(item as MapString, dynamic)) .toList(); }写入时我先把数据序列化成字符串再一次性写入文件避免频繁打开文件句柄。并发写入的问题我暂时没有做锁处理因为编辑家人是低频操作只要 UI 上做好防重复点击基本不会遇到竞态。4. 编辑页 UI 与表单实现4.1 页面布局编辑入口分成三个区块编辑家人页面的布局我分成了三个区域顶部是头像区中间是基本信息表单底部是备注和保存按钮。分区的好处是编辑体验更清晰用户打开页面就知道大概要填哪些内容。头像区放在最上方左侧显示当前头像或文字头像右侧是更换照片和清除照片两个操作按钮。点击更换照片会触发图片选择流程这个流程在 OpenHarmony 上有特殊之处后面单独讲。基本信息表单用 Flutter 的Form组件包裹包含姓名输入框和关系选择器。关系选择器提供一个下拉列表选项是常用的家庭关系爸爸、妈妈、儿子、女儿、爷爷、奶奶、外公、外婆、其他。我没有让用户自由输入关系因为自由输入会造成数据不统一后面做按关系筛选时会很难处理。如果列表不够用额外提供一个自定义关系入口。生日是个可选项点击后弹出日期选择器。这里我直接用了 Flutter 自带的日期选择控件表现中规中矩没有在 OpenHarmony 上出现兼容问题。备注放在最底部是一个多行输入框限制 50 个字以内避免备注过长影响列表展示。4.2 头像选择图片插件不可用时的兜底方案做头像选择时我遇到了本次开发中最折腾的一个问题。标准 Flutter 项目里选图通常用image_picker插件但在 OpenHarmony 适配分支上这个插件的原生实现并不一定存在。运行时点击按钮直接抛出MissingPluginException提示找不到平台通道的实现方法。排查之后发现不是代码写错了而是这个插件没有对应的 ohos 原生模块。遇到这种情况有两条路第一找一个支持 ohos 的图片选择插件社区里已经有团队在做 OpenHarmony 的插件适配第二在自己的ohos工程目录下写一个极简的原生模块用 MethodChannel 把原生相册打开选中图片后把文件路径返回给 Flutter 侧。我最后采用的是第二条路因为可控性最高。原生侧只做了两件事拉起系统相册、把选中图片拷贝到应用沙盒目录并返回路径。Flutter 侧通过一个封装好的pickImage()方法调用拿到的路径直接传给编辑页显示。具体原生代码因适配分支而异这里不展开只强调这个思路在生态不完善时原生兜底是绕不开的能力。如果你暂时不想碰原生还有一个更简化的方案上传时直接支持手动输入图片路径或从固定目录读取测试图片。这个方案只适合开发调试不适合实际使用但能让你先验证完整个编辑器流程。4.3 表单校验错误提示要具体表单校验用的是 Flutter 自带的FormTextFormField的validator机制。姓名是必填项也是唯一必填项校验逻辑是validator: (value) { final name value?.trim() ?? ; if (name.isEmpty) return 请输入家人姓名; if (name.length 20) return 姓名不能超过20个字; return null; }校验信息要写得具体一些不要只写格式错误。老人和孩子看不懂格式错误是哪里错直接说姓名不能为空或太长了问题一目了然。姓名输入框我设置了maxLength: 20但注意maxLength默认会显示一个字数计数器如果觉得丑可以通过设置decoration.counterText: 隐藏。关系选择器如果选的是其他会再出现一个文本框让用户填具体关系这个联动用ValueListenableBuilder实现。选择器本身的值存在 State 里切换时自动隐藏或显示自定义输入框。保存按钮的校验走的是_formKey.currentState?.validate()校验失败时 Flutter 会自动为每个非法输入框显示错误信息不需要额外处理。校验通过后再进入保存逻辑。4.4 保存动作与防重复提交保存时要做两件事把表单数据写入模型然后调用仓库的保存方法。这里有个细节新建成员和编辑成员共用同一个页面通过构造参数区分。如果是编辑页面加载时就把已有数据填充到输入框如果是新建所有字段为空。Futurevoid _save() async { if (!_formKey.currentState!.validate()) return; setState(() _saving true); final member FamilyMember( id: widget.member?.id ?? _generateId(), name: _nameController.text.trim(), relation: _relation, birthday: _birthday, avatarPath: _avatarPath, avatarColorValue: _avatarColor, remark: _remarkController.text.trim(), ); await _repository.saveMember(member); if (!mounted) return; Navigator.pop(context, member); }保存按钮在_saving为 true 时置灰防止用户连续点击导致重复写入。Navigator.pop时把保存好的模型作为返回值带回上一页这样上一页不需要重新读取文件也能立即刷新列表。_generateId()用的是时间戳加随机数简单避免碰撞。在 OpenHarmony 上异步保存完成后需要额外检查mounted否则组件已经销毁时调用Navigator.pop会报错。这个检查在标准 Flutter 上也适用但适配分支上更容易触发因为原生页面切换时机更不可控。5. 状态管理、回传和列表刷新5.1 轻量状态管理为什么这次不引入重量级框架家庭成员编辑页面本身的状态很有限无非是几个输入框的值、一个是否正在保存的标记、一个头像路径。这种量级用StatefulWidget加上局部setState就够了强行引入 Provider 或 Riverpod 反而增加理解成本。真正的状态管理挑战在上一页的成员列表。列表页需要保持两部分状态当前加载到的成员列表、当前的开编辑状态。我用的方案是列表页自身持有成员列表编辑页返回时通过Navigator.push的返回值来更新单条数据。这个方案的优点是简单直接不依赖任何状态管理库。缺点是当页面层级变深时比如相册详情页里也要编辑人信息就需要把状态往上抽。目前阶段家庭成员编辑只发生在列表页和详情页两个入口临时共享完全够用。5.2 用 Navigator 返回值完成跨页更新上一页调用编辑页的代码是这样写的Futurevoid _openEditor([FamilyMember? member]) async { final result await Navigator.pushFamilyMember( context, MaterialPageRoute( builder: (_) EditMemberPage(member: member), ), ); if (result null) return; setState(() { if (member null) { _members.add(result); } else { final index _members.indexWhere((m) m.id result.id); if (index ! -1) _members[index] result; } }); }用Navigator.push的返回值有一个隐喻pop传出的数据就是编辑的结果。这个写法在 Flutter 里非常常见也很直观。我在 OpenHarmony 上运行时发现一个问题在原生页面切换动画期间如果用户按返回键取消编辑返回值是null此时上一页不应该做任何操作。上面代码里的if (result null) return;就处理了这个场景。如果用户编辑完直接按了系统返回键而不是点保存按钮同样返回null数据不会丢也不会错。这个交互逻辑用户很容易理解不点保存就等于放弃修改。5.3 列表刷新策略不要每次回到列表都重新读文件编辑页返回后列表页直接修改内存中的_members列表就够了不需要重新读文件。只有两种情况需要重新读文件应用冷启动、或者从其他页面比如详情页修改了同一个成员的数据。我一开始很担心内存数据和文件数据不一致每次返回列表都调用一次loadMembers()结果模拟器上出现了明显的卡顿。后来想明白了文件读取本身不慢但每个编辑页返回时都重新解析 JSON、重新构建列表项这个过程让帧率掉了不少。家庭相册的成员列表一共才十几个人完全没必要这么频繁地全量读取。现在的做法是列表页只在initState时读取一次文件后续所有增删改都直接操作内存。保存动作由编辑页或仓库层负责写文件列表页只关注内存状态。只要保证写入文件成功后再pop就不会有内存和文件不一致的问题。6. 实测踩到的五个坑6.1 图片选择组件直接 MissingPluginException这个坑我已经在前面提过这里是完整的排查过程。现象点击更换照片控制台报错MissingPluginException错误信息里能看到image_picker的通道名。我当时第一反应是重新执行flutter clean和flutter pub get但重启工程后问题依旧。排查思路是先确认插件是否有 ohos 平台实现。打开插件的包目录看里面有没有ohos文件夹或者看 pubspec 里是否声明了对 ohos 平台的支持。如果都没有说明这个插件根本不支持 OpenHarmony只能换方案。我最后用原生兜底的写法只花了一个晚上就解决了而排查插件问题花了两个晚上。结论是在 OpenHarmony 适配期遇到平台通道相关的问题先查插件有没有 ohos 实现没有就直接做原生兜底不要死磕。6.2 编辑页面键盘弹出后把保存按钮顶飞在 OpenHarmony 模拟器上TextField 获得焦点时键盘弹出页面下方的内容会被顶出可视区域保存按钮完全看不见了。这个问题在标准 Flutter 上通常用resizeToAvoidBottomInset来解决默认值是 true理论上会自动缩小页面。但在 OpenHarmony 的适配分支上这个默认行为有时不生效。我的解决方式是给整体内容套一个SingleChildScrollView并在底部留出足够的 padding。键盘弹出后用户可以通过滚动找到保存按钮虽然不如自动缩进优雅但至少可用。顺手还加了TextInputAction.done用户点键盘上的完成按钮也会尝试触发保存逻辑。6.3 中文输入偶尔出现字母滞留在输入中文姓名时我遇到了输入拼音后字母不消失、直接显示在输入框里的问题。具体场景是在拼音输入法下输入zhang预期是选字后变成张但适配分支上选字后拼音字母还留在输入框里要把字母手动删掉才能保留中文。这个问题的根因大概率是输入法与 Flutter 文本输入通道之间的组合事件没有完全同步常见于第三方输入法。我用了一个折中的办法在输入框的onChanged回调里如果发现当前文本同时包含中文字符和连续的英文字母就在文本更新后延迟 100ms 做一次清洗把重复的字母部分去掉。这个方法不优雅但实测能把误输入的频率降到很低。如果你用的是系统自带输入法这个坑可能完全不会遇到。6.4 沙盒文件目录首次访问为空保存成员时遇到过FileSystemException提示目录不存在。原因是通过getApplicationDocumentsDirectory()拿到的路径在应用第一次启动时还没有被创建直接在该路径下写文件就失败了。标准 Flutter 上这个目录通常已经存在但 OpenHarmony 的沙盒初始化和 Flutter 引擎启动顺序不完全一致导致首次访问时目录还没就绪。修复方式是在读取和写入前统一做一个目录检查FutureDirectory _ensureDir() async { final dir await getApplicationDocumentsDirectory(); if (!dir.existsSync()) { dir.createSync(recursive: true); } return dir; }这个防御性检查没什么成本但能避免冷启动场景下的偶发崩溃。6.5 成员列表项在滚动时出现白块闪烁打开成员列表页后快速上下滚动部分列表项会出现短暂的白块闪烁看起来像是渲染没有跟上。这种情况在 OpenHarmony 模拟器上出现的频率比真机高可能是模拟器的渲染性能偏弱也可能是 Flutter 适配分支的栅格化策略和标准平台不一致。我的优化手段是列表项用RepaintBoundary包裹减少不必要的重绘区域。同时把列表项里的图片读取改成了异步加载避免在自绘线程中同步读文件导致卡帧。改完之后闪烁明显减少但不能完全根除毕竟模拟器的性能基线就摆在那。如果真机上没有这个问题建议以真机表现为准。7. 下一步把成员真正接入相册索引编辑成员的流程跑通后家庭相册的底层数据就算有了雏形。接下来自然的方向是把成员和照片关联起来照片详情页可以给照片标上里面有哪些人相册首页可以按成员维度生成一个聚合索引点击某个成员就能看到他参与的所有照片。更进一步未来还可以做基于照片的人脸分组从照片里自动识别出可能的人选再让用户确认和修正。这些功能都建立在可靠的成员数据之上。这次把编辑家人做得足够稳后面的扩展就不会被基础数据问题拖后腿。我在实际做的时候还有一个体会功能开发前先把数据模型想清楚后面的大多数 UI 工作都会变得顺理成章。如果你也在做类似的应用建议也按照先人、后照片、再时间线的顺序来推进亲测要比一上来就做瀑布流省事得多。