
身边不少做手工的朋友都问过我同一个问题你那些步骤图是怎么记得这么清楚的其实不是我记忆力好而是从很久以前开始我就强迫自己把每次做皮具、绕线首饰、木作的完整过程都沉淀成一份可以复用的教程。试过相册存图、备忘录写字、群里发消息结果就是素材散落一地真到要整理的时候连一张过程图都找不到。后来我直接用手里的工具——Flutter做了一款跨平台的手工DIY教程记录本中文名叫“手作手账本”核心就一句话记录每一个创意时刻。这次除了Android和iOS我还同步把鸿蒙端接上了等于把跨平台开发这件事从环境搭建到最终出包完整地踩了一遍。这篇文章适合两类人一类是想做内容创作工具的Flutter开发者可以参考核心模块设计和数据组织另一类是公司要求同时出鸿蒙版本、但还不知道怎么落地的跨平台团队。我会把适配过程中的关键决策、实际操作的步骤、踩过的坑都写出来尽量让文字可以直接当操作手册用。1. 先想清楚“记录什么”再谈用什么框架写很多人一上来就问Flutter能不能跑鸿蒙、性能怎么样我反而觉得产品逻辑没理清之前聊框架都是空的。手工DIY教程记录本不是普通笔记软件它的核心是把一个非线性、碎片化的创作过程整理成一条可复现的线性教程。这个定位决定了整个技术方案。1.1 一本手账真正要解决的事把灵感变成教程我最初的版本做得极其简单一个列表加一个富文本编辑框拍照就往里塞。用了一周发现问题严重——手作过程里最值钱的信息是“顺序”和“依赖关系”。比如绕线饰品先弯骨架再缠线最后固定配件顺序乱了成品就废。而相册里的照片天然只有拍摄时间没有步骤关系事后根本排不回来。所以重新设计时我确定了几条产品底线记录单位不是“一条笔记”而是“一个项目”项目内部有步骤步骤有顺序。每一步可以挂多张图、一段文字、一个预估工时和一个难度标签。材料清单和项目绑定方便回头统计成本。支持“进度状态”流转灵感、备料、制作中、已完成。所有数据必须本地优先没有网络也能随时记录。这些底线直接决定了数据模型的长相。后面章节会给出具体表结构和Dart类设计。这里我只想说一句如果你要把手作过程做好请把“教程”当成一等公民来设计而不是把“日记”和“相册”拼在一起。1.2 跨平台方案横评为什么是Flutter而不是别家标题里带了“跨平台”那免不了把主流方案摆出来比一轮。我做这个应用时的备选有Flutter、uniapp、Electron、Tauri以及Kotlin Multiplatform。最终选了Flutter理由很实际。方案UI一致性移动端性能鸿蒙适配我的真实感受Flutter自绘引擎像素级一致强滚动列表表现稳定已有适配方案编辑器类UI写起来最顺手uniapp依赖WebView渲染叠加复杂编辑场景容易掉帧可用但原生能力要看运气上手快深挖卡Electron桌面端优秀移动端基本没有手机上是灾难有移植教程但工程量大不适合做移动端记录工具Tauri好但移动端插件生态薄中等有社区尝试图片密集场景绕不过原生插件Kotlin MultiplatformUI层需要各端分开写本身不解决UI支持有限工作量直接翻倍比较下来Flutter的优势不只是“一套代码两端跑”而是它的UI自绘机制让复杂界面在不同设备上的表现差异最小。对我这个应用来说步骤编辑器里有大量的拖拽排序、图片卡片堆叠、左右手滑动操作用uniapp那种偏Web的渲染方式到了中低端设备上很容易变成纸片人体验。关于鸿蒙适配我还多说一句现在的适配不是“官方Flutter开箱即用”而是基于HarmonyOS的SDK和Flutter引擎做了一层桥接。你在工程里仍然写Dart但构建目标变成了鸿蒙应用包HAP。这个我在下一章详细说。2. Flutter工程接入鸿蒙从SDK到工程结构再造这一章是整个跨平台开发里最容易让新人放弃的地方。很多人以为装上Flutter、建个项目就能直接打鸿蒙包真到了flutter build才发现根本没有这个target。我花了一整个周末才把链路走通下面把这些步骤拆开讲。2.1 环境准备两个IDE和一个特殊分支要完成鸿蒙侧的Flutter开发你至少需要DevEco Studio鸿蒙应用的集成开发环境负责编译HAP、管理SDK、跑模拟器。HarmonyOS SDK通过DevEco Studio的SDK Manager下载里面包含API、工具链和系统镜像。适配过鸿蒙的Flutter SDK这不是从flutter官网直接下的那个而是走OpenHarmony适配分支的引擎需要单独clone并切换分支。我当时把原版Flutter SDK路径和适配版SDK路径分开维护通过环境变量切换export FLUTTER_ROOT/path/to/flutter-ohos export PATH$FLUTTER_ROOT/bin:$PATH flutter doctor这里有个容易踩的坑如果你本机的Android Studio、DevEco Studio同时存在flutter doctor会扫描到两套工具链有时会报设备冲突。我的做法是只保留适配版SDK的路径映射Android开发归Android开发鸿蒙开发通过独立的shell环境启动两个环境互不干扰。2.2 工程的目录结构多了一个“壳工程”Flutter标准工程结构是lib/、android/、ios/接入鸿蒙之后工程里还会出现一个鸿蒙壳工程目录里面是用ArkTS写的入口和桥接代码。结构大概是这样my_handbook/ ├── lib/ # 所有Dart业务代码 ├── android/ # Android壳工程 ├── ios/ # iOS壳工程 ├── ohos/ # 鸿蒙壳工程 │ ├── entry/ │ │ └── src/main/ │ │ └── ets/ # ArkTS入口承载Flutter引擎 │ └── build-profile.json5 └── pubspec.yaml我建议把鸿蒙壳工程当成一个独立模块看待不要在里面写太多业务逻辑它的职责就是启动Flutter引擎、注册平台通道。业务代码全部留在lib/这样三个平台共享同一个Dart包维护起来才轻松。鸿蒙壳工程的创建不需要手动写我用的方式是先创建一个标准Flutter项目再用适配工具把鸿蒙壳工程生成出来。生成完先跑一次空壳确认HAP能装上再往里面集成业务代码。别一上来就把整个项目搬进去否则排查问题时你根本分不清是Flutter报错还是壳工程报错。2.3 大文件拆分Dart的part关键字在这里帮了忙接入过程中我遇到一个工程组织问题教程记录本的功能模块越来越多但Dart没有原生的“多文件Class”概念所有Class拆分都需要通过import和part来组织。part和part of这套机制很多人只在别人的源码里见过真正在项目里用的时候还挺有用。我的做法是把“教程编辑器”这个大文件拆成几个part文件让同一个库内的私有方法可以跨文件访问。类似这样// editor.dart part editor_steps.dart; part editor_materials.dart; class CraftEditor { // 主逻辑 }这样拆的好处是编辑器内部状态、步骤渲染、材料面板这三大块可以分文件维护同时共享主库的私有变量减少写大量getter。但part也有个坑——一旦使用part编辑器里的命名空间会扩大两个子文件里的同类方法容易重名覆盖。我的经验是只在“内聚度高、共享私有状态强烈”的模块里使用part普通页面还是老老实实用import。结构清晰永远比少写几行import重要。3. 教程记录本的数据设计与状态管理Cubit和轻量数据库数据层是这个应用的命脉。我经历过“用共享Preferences存一切”的惨痛阶段到项目里有几百个步骤时读写卡顿、数据错乱、图片路径丢失全都来了。所以从重做第一版开始我就坚持用一个正经的关系型数据库并用状态管理框架把UI和持久化隔离。3.1 数据模型项目、步骤、材料三张表打底手工DIY教程记录本的核心模型我用三张表表达class CraftProject { final String id; String title; String coverPath; ProjectStatus status; ListCraftStep steps; ListMaterialItem materials; } class CraftStep { final String id; final String projectId; String title; String description; ListString imagePaths; int orderIndex; int durationMinutes; String difficulty; } class MaterialItem { final String id; final String projectId; String name; int quantity; String unit; double cost; }数据库我用了sqflite并在表上建了索引。步骤查询按projectId orderIndex排序材料按projectId查询。数据量级在千条以内不用上太重型的ORM保持SQL直观。这里有个设计细节值得分享步骤的图片不是存一张大图而是存原图路径加压缩图路径两个字段。原图用于导出和打印压缩图用于列表展示和快速加载。缩略图在拍摄后异步生成生成完再更新数据库记录避免主线程卡顿。3.2 为什么状态管理选Cubit而不是Bloc项目刚开始时我用的是Bloc事件驱动确实规范但写着写着就发现成本太高。每个页面改动都要定义Event、写State、处理Mapper对于教程记录本这种“局部状态为主、交互微操作多”的工具类应用有点大炮打蚊子。后来我换成Cubit它是Bloc的轻量版不强制事件流直接调方法修改状态。例如步骤排序时的状态更新class StepListCubit extends CubitListCraftStep { StepListCubit() : super([]); Futurevoid reorder(int oldIndex, int newIndex) async { final steps ListCraftStep.from(state); if (oldIndex newIndex) newIndex - 1; final item steps.removeAt(oldIndex); steps.insert(newIndex, item); emit(steps); } }这种写法的好处是UI层代码干净直接context.readStepListCubit().reorder(...)就行不需要为每个动作单独定义Event。当然Cubit也有它的边界如果你要做全局审计、复杂状态回溯还是Bloc更合适。我的判断标准很简单——“编辑器内的状态”用Cubit“跨页面共享且需要回放的状态”才考虑Bloc。3.3 状态持久化每次变更是写数据库还是写内存我采用的方式是“内存优先、定时落盘”。用户拖拽排序、修改文字、添加图片时所有操作只改内存中的Cubit状态界面即时响应等到用户切换页面或点击保存时才批量写入数据库。这个设计和很多人的直觉相反他们喜欢每次编辑都立刻更新数据库。但手作过程里用户会一边拍照片一边写备注连续几十次操作每次都做数据库事务很容易卡顿。我的方案是快速操作全部在内存离开编辑页或App退到后台时统一保存。唯一要注意的是确保保存回调覆盖到所有退出路径否则用户辛苦记的步骤就丢了。4. 图片密集场景下的平台通道实战相册通路与系统事件记录本应用的一个核心动作是“拍一张步骤图”。在普通Android/iOS上我直接用了现成的image_picker插件但在鸿蒙端第三方插件支持参差不齐有些包还没适配。这就逼着我自己动手写平台通道。4.1 为什么非要动平台通道插件鸿蒙化的现实跨平台开发的一个常见幻觉是“用了Flutter就有所有插件”。实际上鸿蒙生态里的Flutter插件数量远不如Android/iOS图片选择、相册多选、缩略图读取这类能力很多pub包要么没有鸿蒙端实现要么只实现了部分接口装上以后直接调不通。我的策略是先查插件是否有ohos/目录没有就优先绕行。比如相册图片缩略图我直接通过MethodChannel调鸿蒙侧系统接口绕开了不可靠的第三方包。MethodChannel读取最近20张图片缩略图Dart侧代码如下static const _channel MethodChannel(com.handbook/album); FutureListAlbumImage loadRecentImages({int limit 20}) async { final List? result await _channel.invokeMethod( loadRecentImages, {limit: limit}, ); return result ?.map((e) AlbumImage.fromJson(MapString, dynamic.from(e as Map))) .toList() ?? []; }鸿蒙壳工程里用ArkTS实现同名Channel并返回图片列表注册后即可调用。这里最需要注意的是MethodChannel的名字必须两端完全一致大小写都不能差以及返回的数据结构要和Dart侧约定好我踩过的坑是侧返回了带引号的字符串Dart侧按JSON解析费了半小时才定位到类型问题。4.2 EventChannel监听相册变化补上“拍完要立刻用”的闭环MethodChannel解决的是“主动拉取”但教程记录本还有一个高频场景用户拍完一张过程照App要第一时间提醒“是否把这张照片挂到当前步骤”。这就属于“原生到Flutter的持续事件推送”是EventChannel的典型应用。我在Dart侧写了一个事件流class AlbumObserver { static const _eventChannel EventChannel(com.handbook/album_events); StreamAlbumImage onNewImage() { return _eventChannel .receiveBroadcastStream() .castString() .map((path) AlbumImage(path: path)); } }鸿蒙侧在相册数据变化回调里把新增图片的路径通过EventSink.success推给Dart层。这样用户拍照的一瞬间App就能弹出浮动提示。这里也有个容易忽略的细节EventChannel必须有收听方才能推送如果Dart侧页面没在监听原生侧的sink会把事件丢掉。所以我的做法是在进入编辑页时开启监听离开页面时关闭避免事件积压和内存泄漏。4.3 图片压缩链路从拍摄到展示三步不能省手机原生拍出来的照片动辄3MB以上如果直接放进Flutter列表加载速度会非常难看。我在压缩链路里做了三步拍摄成功后立即用原生侧生成宽边为1080px的预览图。预览图存入应用缓存目录数据库记录路径。Flutter加载时Image组件的cacheWidth设为720让引擎从解码阶段就减少内存占用。这一步做完列表滚动立刻变顺。别小看cacheWidth很多卡顿不是渲染问题而是图片解码带来的内存和CPU开销。5. 没有真机时怎么调鸿蒙模拟器、云真机与抓包做鸿蒙开发遇到的最大尴尬是手边没有鸿蒙手机。我一度以为只能干瞪眼后来把能用的工具都试了一遍发现调试路径其实比想象中多。5.1 设备从哪来模拟器、预览器、云真机DevEco Studio自带模拟器可以在SDK Manager里下载系统镜像启动后用hdc连接和Android的adb用法高度相似。Previewer可以在不启动模拟器的情况下预览ArkTS页面适合调试壳工程UI和通道注册。远程云真机属于付费服务偶尔用于做真机上的性能和相机验证。我的常用路径是业务逻辑在Flutter测试中跑壳工程界面用Previewer整机验证用模拟器只有涉及相机、相册、传感器时才去申请云真机。真机最大的价值是验证平台通道是否真的被原生侧响应了很多通道问题模拟器上是看不出来的。5.2 从Android侧先跑通业务再切换鸿蒙target这个策略帮我省了非常多时间。教程记录本的数据模型、Cubit状态、编辑页面绝大部分代码与平台无关我先是把它跑在Android模拟器上把所有逻辑和UI调稳定。等到Engineering Ready了才切到鸿蒙target验证平台通道和壳工程。切换方式是在工程里维护两个构建配置一个Android target一个鸿蒙target。flutter run默认跑Android鸿蒙侧用DevEco Studio打开壳工程单独构建。一开始很多人把这当成两条死链路其实它们共享同一个lib/目录业务代码改一遍两端同时生效。5.3 Charles抓鸿蒙包证书和代理的坑联调接口时免不了要抓包Charles在鸿蒙上的抓包流程和Android类似但有细微差别。手机和电脑连同一个Wi-Fi设置代理后手机上需要安装Charles的HTTPS证书否则只能看到CONNECT请求看不到明文内容。和Android相比鸿蒙的系统证书信任策略更严格用户自己安装的证书默认不被应用信任。我查了一圈发现需要在鸿蒙的设置里进入“证书管理”手动开启对应用户证书的信任开关。装好之后HTTPS解密才能生效。另外鸿蒙抓包不能像Android一样直接adb给所有应用装证书每个应用是否信任用户证书还跟它的网络安全配置有关。如果发现某个应用还是抓不到包先检查是不是应用自身配置了只信任系统证书。5.4 跨平台一致性检查清单调完鸿蒙之后我整理了一张CheckList每次发版前都要过一遍图片选择、保存图片到相册是否正常。系统字体缩放时步骤标题是否溢出。深色模式下卡片背景和文字对比度是否达标。从后台恢复时Cubit状态是否从数据库正确恢复。无网络状态下新增项目能不能正常保存。这张清单看着基础但每一条都在某个平台上出过问题。跨平台开发的成本很大程度就耗在这些“两端不一致”的细节上。6. Impeller渲染优化和几个“手不跟手”的体验细节功能做完整后我开始抠体验。手工DIY记录本的使用场景很特殊用户可能在操作台旁边一手拿着材料另一手在手机上记录这时候任何卡顿、掉帧、点击无反馈都会让人当场血压上升。这一章说几个我实际调过的体验点。6.1 Impeller渲染引擎到底有没有用Flutter这几年一直在推Impeller渲染引擎用于替代原来的Skia。它的核心优势是引擎预编译shader避免了Skia那种“新页面首次绘制时频繁编译shader导致的掉帧”。我在鸿蒙适配分支上做了切换测试直观感受是列表页首次滚动的掉帧明显减少步骤编辑页里大量圆角卡片、阴影叠加这类场景渲染稳定性好了不少。但注意Impeller并非在所有设备上默认开启你需要在构建配置里显式打开编译开关。老设备上如果发现新引擎兼容性有问题可以随时切回Skia。我的建议是先把开关打开用真机跑一遍核心场景如果遇到纹理异常再回退别因为网上的言论提前劝退。6.2 TabBar切换动画取消掉才是跟手记录本的主界面有几个Tab灵感、进行中、完成、材料库。默认的TabBar样式带一个滑动动画实际体验下来切换时总感觉慢半拍。后来我在点击事件里直接改了选中的索引不触发自带动画TabController controller; void switchTab(int index) { if (controller.index index) return; controller.animateTo(index, duration: Duration.zero); }把动画时长设为零不代表界面变得生硬反而是让切换更直接。对工具型应用来说“快”就是最好的反馈。这里我学到的一个底层道理动画不是越多越好有些高频交互的动画只会让人觉得App反应慢。6.3 列表滚动卡顿别让图片解码拖垮帧率步骤列表里每一行都可能有图片这也是最容易掉帧的地方。除了前面提到的cacheWidth还有一个技巧是给图片组件加gaplessPlayback: true让图片切换时不闪白。最理想的方案是列表只显示缩略图等用户点开大图时才加载原图。我测试下来1080px预览图在手机上显示已经足够清晰2024年的中端机跑这个列表都能稳帧。6.4 发布前的包体和签名HarmonyOS出包需要配置签名文件。我用自动签名模式把签名信息放到配置里方便CI打包。包体方面Flutter引擎本身会占用一部分体积教程记录本全量构建后约在40MB左右其中引擎占了一半以上这个体积在可接受范围内。如果后续做轻度化可以考虑拆分so库按ABI加载当前阶段没这个必要。最后分享一个我坚持了很久的习惯每完成一个版本的记录本开发我都会用它在真实的手作过程中记录一条完整的教程。这个动作比任何测试用例都管用它能让你迅速发现“数据丢失”“步骤排序不对”“图片加载慢”这类用户才会在意的问题。工具是给自己用的就一定要把自己当成最挑剔的用户。