上个月清理抽屉翻出一堆大大小小的印石。有的还裹着宣纸有的已经起了稿子还有的刻到一半扔在角落。我盯着其中两三块完全想不起来是什么时候入的、花了多少钱、什么石种只记得当时觉得品相难得。作为一个平时写代码、周末弄篆刻的人这个场面多少有点打脸。那天之后我决定用自己最顺手的 Flutter 框架做一个小工具篆刻石料记录应用。它要解决的核心问题其实很朴素——让每块石头都有档案把石种、规格、重量、入手时间、价格、当前状态、照片集中放在一处随手可查。我的主力手机是鸿蒙系统所以第一目标平台就是鸿蒙Flutter 的跨平台能力又能保证以后想同步到 Android、iOS 时不用重写。这篇教程会从需求拆解讲起经过工程搭建、数据模型、页面实现、图片备份一直讲到鸿蒙打包安装把我完整走通这条路的过程和踩过的坑都交代清楚。1. 为什么刻印人的桌边需要一本电子石料账需求拆解与功能边界1.1 石料档案管理到底在管什么篆刻的事情看着小但材料管理的复杂度比多数人想象的高。一块印石从入手到最终变成作品中间要经过设计、起稿、镌刻、修整、钤印好几个阶段再加上有人是纯收藏有人会转卖或赠人状态就更多了。传统玩法里有这么几种记账方式我身边的朋友基本都试过纸质本子记起来方便但查的时候难还不能带照片时间一长字迹和记忆一起模糊。表格文件字段可以做得细可它本质是个办公工具在手机上看又挤又不顺手线下开电脑才想得起来更新。手机相册照片是最直观的但相册不提供结构化字段石种、尺寸、价格全靠脑补搜索基本靠运气。所以电子石料账真正要解决的是一致性问题给每块石头一个固定的档案所有信息在同一处维护并且打开手机两三下就能完成一次登记或查询。1.2 功能清单与优先级表我在动手前先列了一份需求清单并且给每一项标了优先级。这步很值得做因为后面所有页面设计都会受到它约束。功能说明优先级石料登记录入名称、石种、尺寸、重量、价格、入手时间、来源P0照片附件每块石料可挂多张照片拍照或从相册选择P0列表检索按名称/备注搜索按石种、状态过滤按时间排序P0状态管理毛料、起稿、镌刻完成、自藏、赠出、转让等P0详情页查看完整档案、轮播照片、快速改状态P1统计看板各类状态数量、入手花费合计、石种分布P1备份导出把全部记录与图片打包导出/还原P1云同步多设备同步需要考虑账号体系P2暂不做P0 是必须有的P1 是能明显提升体验的P2 直接砍掉。很多个人工具失败的原因不是功能不够而是功能太多。一块石头要拍的字段其实就那几个核心诉求永远是快和稳。1.3 边界先做本地单机不做云同步这个产品有一个明显边界必须承认它是单机应用数据只存在手机本地文件目录里。理由不复杂——用户量就自己一个人最多加身边几个刻印朋友远没到需要后端服务的规模。云同步、多端共享属于后话现在引入账号体系和服务器只会拖慢整个项目。这也决定了技术选型的方向所有数据操作都在本地完成存储方案要足够简单可靠备份就手动导出文件。后面我会详细说为什么这个决策让我避开了不少麻烦。2. Flutter 跑上鸿蒙的工程搭建环境配置里的三个关键差异2.1 需要装的东西和版本配对Flutter 跑鸿蒙目前并不是开箱即用的一等公民但相关工具链已经比较成熟。我们需要四样东西Flutter SDK、鸿蒙配套的 IDE一般简称 DevEco Studio、鸿蒙 SDK、设备连接工具 hdc。最容易在这步翻车的不是安装本身而是版本配对。Flutter 的鸿蒙适配分支通常绑定某个 OpenHarmony SDK 版本IDE 也会要求特定的工具链版本。我的经验是先确定 IDE 能创建的鸿蒙工程模板版本再去找对应能编译出 hap 的 Flutter 工具链而不是各自装最新版。如果 flutter doctor 里鸿蒙那一列长期显示红色九成是 SDK 路径或版本不匹配。具体配置方面需要把 SDK 路径交给环境变量或工程里的 local.properties然后在 Flutter 侧确认 OHOS 目标被识别。这一步没法跳过因为 Flutter 本身并不知道鸿蒙 SDK 放在哪里。2.2 创建工程与目录结构的差异环境就绪后创建工程和平时略有区别需要显式指定要生成的目标平台flutter create --platformsandroid,ios,ohos --org com.example seal_stone_app cd seal_stone_app执行完你会发现工程根目录下多了一个ohos/文件夹这就是鸿蒙的原生外壳作用类似android/和ios/那两层壳。lib/目录里的 Dart 代码两端共用鸿蒙壳里是项目配置文件hvigor 工程结构平时几乎不用手改只要保证签名、应用名、图标是对的就行。我之前被惯性思维带偏过以为鸿蒙端就是安卓换皮结果发现它的构建体系、签名机制、运行命令都和 Android 不是一回事。区别整理如下项目Android 目标鸿蒙目标原生工程目录android/ohos/构建系统Gradlehvigor设备连接工具adbhdc产物格式.apk.hap运行调试flutter runflutter run目标设备走 hdc发布构建flutter build apkflutter build hap2.3 验证环境是否真的可用我强烈建议在写任何业务代码之前先做一次完整验证连一个鸿蒙真机或模拟器跑一次flutter run确认能弹出默认的 Flutter 计数器页面。这一步能把环境问题彻底暴露掉否则后面每写一段代码你都会分不清报错到底是代码问题还是工具链问题。# 列出当前可见设备 hdc list targets # 在指定设备上跑 Flutter flutter run -d 设备ID第一次跑通后再补充一个针对鸿蒙的构建验证flutter build hap --debug它能提前确认整个编译链路没问题。我当时在这个环节卡了大概半天最后就是 SDK 版本不配对。理顺之后再回头看值得。另外还有一个选型上的提醒鸿蒙生态里纯 Dart 的包通常开箱即用但依赖原生能力的插件相机、文件目录、数据库底层等需要逐一确认是否有鸿蒙适配版本。别一上来就堆一堆 pub 包后面很容易在某个插件上卡住。3. 一块石头的数据档案模型设计、状态机与本地存储选型3.1 一块石头在代码里的字段写代码之前先定义数据模型。我没有把字段设计得过于复杂原则是每个字段都能回答一个实际问题。比如长宽高回答的是这石头能不能装进某个印盒入手时间回答的是我什么时候开始囤石头去向回答的是这块料子最终变成了什么。enum StoneStatus { raw, // 毛料还没动 designed, // 起稿/设计阶段 carved, // 镌刻完成 kept, // 自藏 gifted, // 赠出 sold, // 转让出手 } class StoneRecord { final String id; final String title; // 给这块石头起的名字 final String stoneType; // 石种分类可自定义 final String source; // 入手渠道 final double lengthMm; // 长 final double widthMm; // 宽 final double heightMm; // 高 final double weightGram; // 重量 final String colorText; // 颜色印象 final String texture; // 肌理、冻地、絮状物等 final DateTime acquiredDate; // 入手时间 final double cost; // 入手成本 final StoneStatus status; // 当前状态 final String notes; // 备注设计念头、刀感记录等 final ListString imageRelPaths []; // 照片相对路径 }序列化也保持直接不引多余框架MapString, dynamic toJson() { id: id, title: title, stoneType: stoneType, source: source, lengthMm: lengthMm, widthMm: widthMm, heightMm: heightMm, weightGram: weightGram, colorText: colorText, texture: texture, acquiredDate: acquiredDate.toIso8601String(), cost: cost, status: status.name, notes: notes, imageRelPaths: imageRelPaths, };UI 上还要维护一个石种字典让用户从预设分类里选而不是每次手打。种类型别我建议做成可在设置页自定义的列表因为不同玩印石的人分类习惯差别很大有人按冻地不冻地分有人按产地分先提供一组通用分类再允许增删体验会好很多。3.2 状态机从毛料到转让状态字段我坚持用枚举而不是自由文本因为后续的过滤、统计、状态流转提示都依赖它。完整的流转关系大致是这样raw - designed - carved - kept \- gifted \- sold也允许从 raw 直接 gifted 或 sold因为确实有人收了石头就没打算刻直接作为原石转出去。这里我不做强制约束只把状态枚举定义成有序列表UI 上按这个顺序展示选项避免出现毛料直接变成自藏这种跳变带来的统计困惑。每条记录还应该记录状态最后变更的时间这个字段后来很有用比如我想知道上个月到底刻完了哪几块。3.3 本地存储选型为什么先用 JSON 文件在鸿蒙上做本地存储选择比想象的少一些。我认真对比过三种方案方案优点要付出的代价JSON 文件实现最简单备份就是拷贝跨端迁移容易记录变多后全量加载有压力无查询索引Hive纯 Dart读写快适合键值场景复杂查询不方便缩略图、批量筛选都要自己做SQLitedrift/sqflite 等查询能力强SQL 顺手原生插件鸿蒙端适配情况要逐个确认早期容易卡项目个人工具的记录数量通常在几百到一两千条上限远没到数据库的瓶颈。所以我选了最朴素的 JSON 文件方案一个 records.json 存全部记录内存里维护列表查询用 Dart 的 where 过滤。只要把读写封装成 RecordStore 接口未来真要换 SQLite替换范围也清晰。class RecordStore { FutureFile get _file async { final dir await _appDir(); return File(p.join(dir.path, records.json)); } FutureListStoneRecord loadAll() async { final file await _file; if (!await file.exists()) return []; final data jsonDecode(await file.readAsString()) as List; return data.map((e) StoneRecord.fromJson(e as MapString, dynamic)).toList(); } Futurevoid saveAll(ListStoneRecord records) async { final file await _file; await file.writeAsString(jsonEncode(records.map((r) r.toJson()).toList())); } }这里有一个很容易忽视的点一定要把存储目录做对。鸿蒙应用和 Android 应用一样有沙箱目录不能想当然往根目录写。我的做法是统一取应用文档目录下面建一个seal_app/子目录记录文件和图片都放在里面导出备份时整个目录打包。4. 核心页面的落地过程登记表单、检索列表与统计看板4.1 列表页检索、过滤与排序列表页是整个应用的门面打开应用第一眼就是它。我用了一个顶部搜索框加两个筛选入口石种下拉框、状态下拉框外加一个排序菜单。筛选逻辑不复杂但要把关键词匹配和状态/石种过滤放在同一个连词判断里避免写出两套互相矛盾的逻辑。ListStoneRecord get _filtered { final kw _keyword.trim().toLowerCase(); return _records.where((r) { final matchKw kw.isEmpty || r.title.toLowerCase().contains(kw) || r.notes.toLowerCase().contains(kw); final matchStatus _statusFilter null || r.status _statusFilter; final matchType _typeFilter null || r.stoneType _typeFilter; return matchKw matchStatus matchType; }).toList(); }搜索框我加了一个 300ms 的防抖 Timer避免每敲一个字就去 rebuild 整页。列表项用卡片展示第一张照片做缩略图右边是名称、石种、尺寸、状态徽标。状态徽标的颜色要固定比如毛料灰色、镌刻完成青色、转让黄色这样扫一眼就能知道库里的状态构成比读文字高效很多。TextField( onChanged: (v) { _debounce?.cancel(); _debounce Timer(const Duration(milliseconds: 300), () { setState(() { _keyword v; }); }); }, )列表与网格切换我也做了因为摄影角度不同有人习惯大图浏览有人习惯表格信息流。实现就是一个GridView和ListView的简单切换不复杂但很影响日常使用频率。4.2 登记表单校验和不给用户添堵表单是使用频率最高的页面设计目标只有一个把记录一块新石头的时间压缩到十秒以内。我做的第一版表单有十几个必填项结果自己都不想用。后来改成只有三个必填名称、石种、状态其他全部可空默认值帮用户省掉大量操作。final _formKey GlobalKeyFormState(); Widget _buildTitleField() TextFormField( decoration: const InputDecoration(labelText: 石料名称), validator: (v) (v null || v.trim().isEmpty) ? 给这块石头起个名字吧 : null, onSaved: (v) _draft.title v!.trim(), );数字字段的输入要额外做一层限制。长度和重量理论上可以是小数但也不能允许一串小数点我用一个输入格式化器限制只允许数字和小数点TextFormField( keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r^\d{0,4}(\.\d{0,2})?)), ], )状态选择我用了 ChoiceChip 一排平铺比下拉框省一次点击。日期选择默认今天来源和价格留空即可。保存时只做一次_formKey.currentState.validate()通过后调用 onSaved 把值写入草稿对象再传给 RecordStore 持久化。保存完成后弹一个 SnackBar同时列表页自动刷新首图这个反馈闭环很重要。4.3 详情页与状态快速变更详情页展示一块石头的完整档案照片轮播、各个属性字段、备注、状态历史。属性展示我用一个简单的两列表格字段多的时候比自定义布局更好排版也不会因为长短不一而错位。状态变更不需要进编辑页直接在详情页底部放一排操作按钮刻完了、已赠出、已转让点一下立刻更新。我在这个位置专门记录了变更时间后续统计这个月完成了哪些作品就靠它Futurevoid _changeStatus(StoneStatus next) async { setState(() { _record _record.copyWith(status: next); _record.lastStatusChanged DateTime.now(); }); await _store.saveAll(await _store.loadAll()..replace(...)); }删除操作放在最下面并且要二次确认弹窗。这种数据是心血积累误删一次会很难受我宁可麻烦一步。4.4 统计看板简单卡片比图表更实用一开始我想给统计页引入开源图表库搞点柱状图饼状图。试了一圈发现图表库在鸿蒙端的原生适配不一定顺利而且对几百条数据来说图解反而没有数字直观。最后我用一组统计卡片解决效果意外地好。final byStatus StoneStatus, int{}; for (final r in _records) { byStatus[r.status] (byStatus[r.status] ?? 0) 1; } double totalCost _records.fold(0, (sum, r) sum r.cost);统计页固定显示几项总石料数、按状态分布、总入手成本、平均成本、最近一个月新入手的数量、最近一个月完成镌刻的数量。没有图表没有动画信息密度却比图表高。这算是我在这个项目里学到的一个重要教训功能的价值取决于使用场景而不是技术难度。5. 图片附件与备份导出让照片和档案始终在一起5.1 照片必须入库不能只存相册路径照片附件是石料记录的灵魂。没有照片的石料档案基本等于没记。但图片处理这里有一个新手一定会踩的坑直接从相册返回的路径其实是缓存路径随时可能被系统清理而且相册里的照片本身也会被用户重命名、移动。正确做法是把选中的图片复制进应用自己的文档目录让档案目录成为唯一事实来源。FutureString importImage(XFile file, String recordId) async { final docs await getApplicationDocumentsDirectory(); final dir Directory(p.join(docs.path, seal_app, images, recordId)); await dir.create(recursive: true); final stamp DateTime.now().millisecondsSinceEpoch; final target p.join(dir.path, $stamp.jpg); await file.saveTo(target); return p.relative(target, from: docs.path); }我保存的是相对路径配合应用文档根目录使用。这样导出备份时不用改任何路径整个seal_app/目录搬到新手机就是完整还原。图片选择器的插件在鸿蒙端适配有一些历史坑个人经验是安装前先确认版本是否包含鸿蒙实现不行就退而求其次让用户通过系统文件管理器选择图片文件再复制进应用目录。功能体验差一点但至少稳定。5.2 备份与还原把 JSON 和图片打包成一个 zip单机应用最怕的就是数据没了。我的备份方案很简单把records.json和全部图片文件打包成一个 zip让用户通过系统分享面板存到网盘或电脑。用到的archive包是纯 Dart 实现鸿蒙、安卓、iOS 都能跑不会有原生适配问题。Futurevoid exportBackup() async { final docs await getApplicationDocumentsDirectory(); final appDir Directory(p.join(docs.path, seal_app)); final archive Archive(); await for (final entity in appDir.list(recursive: true)) { if (entity is File) { final bytes await entity.readAsBytes(); archive.add(ArchiveFile(entity.path.substring(appDir.path.length 1), bytes.length, bytes)); } } final zipData ZipEncoder().encode(archive); final outPath p.join(docs.path, seal_app_backup.zip); await File(outPath).writeAsBytes(zipData!); }还原就是反向操作解压 zip 到临时目录先校验里面有没有 records.json再整体复制回应用目录。我特意加了备份时间字段写入 zip 文件名这样多份备份不至于混淆。这个小功能后来成了整个应用里最有安全感的一部分。手机恢复出厂设置、换新机、误操作覆盖数据都因为有备份而变得不可怕。6. 鸿蒙打包与真机安装的实战要点构建、签名与常见报错6.1 构建产物与调试模式开发阶段我是用flutter run直接推包调试的日常改代码编译速度也能接受。但鸿蒙的发布构建要把目标产物从普通的二进制切换成 hap 格式命令如下flutter build hap --release构建产物一般会落在build/ohos/outputs/目录下文件名类似app-release-signed.hap或者不带 signed 的版本取决于你的签名配置。这个文件就可以直接发给其他用户安装或放到鸿蒙应用市场做正式分发。调试和发布模式有一个体验差异要提前知道调试模式下应用会依赖调试服务断开电脑连接或重启后有时会白屏发布包则完全独立。所以发给朋友测试或者自己日常使用都该用 release 构建。6.2 签名配置与版本管理鸿蒙的签名机制和 Android 相似但有区别。开发者需要准备证书文件和 profile 文件。这里不展开讲具体的 UI 操作因为 IDE 版本不同界面会变但逻辑是固定的先生成密钥库再用密钥库和对应的 profile 做签名打包时让构建工具使用这套配置。我遇到过最典型的问题是手机上装了老版本新包签名不一致导致安装失败。解决方法是每次发版都检查三处应用包名没变、签名配置没换、versionCode 有递增。和 Android 的逻辑一致鸿蒙同样靠 versionCode 判断升级versionName只是给人看的。# ohos 工程里的配置文件 app: bundleName: com.example.seal_stone_app versionCode: 3 versionName: 1.2.06.3 常见报错与解决路径把几个高频报错整理成一张表希望对跑这条路的人有帮助现象大概率原因处理方式flutter doctor 里鸿蒙工具链红色SDK 路径没配置或版本不搭核对 local.properties / 环境变量里的 SDK 路径与 Flutter 适配版本编译到一半 hvigor 报错IDE 工具链与 SDK 版本不匹配按提示升级或降级对应组件真机安装提示解析失败签名失效或 profile 过期重新生成签名配置并同步 versionCode图片选择器打不开插件缺少鸿蒙原生实现换纯 Dart 方案或用文件管理器选图后复制入库flutter run 找不到设备hdc 服务没起来或驱动问题执行 hdc list targets 确认设备在线发布包安装后白屏用了 debug 包当发布包改用 release 构建碰到问题第一步是分清是Flutter 层报错还是鸿蒙原生层报错。看错误信息前缀基本能判断Dart 异常会有明确的堆栈hvigor/签名相关的报错则多半在构建阶段弹出。这个排查思路能省掉很多无头苍蝇式的搜索。7. 用了大半个月之后的复盘什么功能真高频什么可以砍掉7.1 高频功能和被冷落的功能应用写完到现在我自己用了大半个月期间又给一位玩篆刻的朋友装了一份。复盘下来结论挺明显真正高频的操作只有三个——拍个照录入新石头、改状态、搜索翻档案。这三个操作每天都会发生所以我把它们都放到了最少点击路径上首页右下角大按钮进登记表单、详情页底部一键改状态、顶部搜索框即输即搜。被冷落的功能也很有参考价值。市场价字段基本没人填重量统计看了一次就不再看了复杂图表在手机上更是没存在的必要。这从反面印证了最开始的判断个人工具做的是信息组织不是数据分析。早把这几个字段从首版去掉我还能再省一周时间。还有一点体验细节记录完一块石头后马上再点新增时我让表单保留上次的石种和来源只清空名称和尺寸。篆刻的人往往会集中入一批同石种的料子这种批量录入动作连续做几次才不痛苦。7.2 下一个版本我打算怎么做复盘之后下个版本的方向变得很清晰。优先级最高的是云备份把 zip 自动同步到网盘省去手动导出。其次是一块石头对应作品的展示页把印蜕、边款照片和石头档案关联起来这样以后翻档案就能看到这块石头最终变成了什么样子。至于多端同步、多人协作这类重功能以现在这个使用规模加了反而是负担。真正让我觉得这个项目值得分享的不是技术有多难而是它完整走了一遍从真实需求到跨平台落地的全过程。一个十几年前需要纸笔和记忆的爱好现在被一个几百行 Dart 的小应用安排得明明白白。你如果也有类似的个人工具想顺手做掉别急着上重型框架和数据库先把最核心的录入、检索、备份三条线跑通它就已经很好用了。