先说这次摸完Flutter for OpenHarmony后的感觉。把一个开源的轻量级记事本App从纸上方案做到能装进开发板、能正常写笔记、能在返回列表后看到刚才的内容,这件事比想象中简单,也比想象中啰嗦。简单的是Dart侧的业务代码几乎不用改,啰嗦的是环境、签名、插件适配这些“系统背书”类问题。这篇文章专门挑记事本里最核心的“笔记编辑器”来讲,从项目选型、工程初始化,到编辑器的数据模型、自动保存、Markdown快捷输入,再到怎么处理平台通道和那些会卡住人的报错,尽量把我实测走过的路完整摊开。
1. 项目定位与技术选型
1.1 为什么是“Flutter for OpenHarmony”
先把一个容易被混淆的点讲清楚。Flutter目前并没有把OpenHarmony作为官方一等平台直接放进主线,OpenHarmony侧能跑Flutter应用,依赖的是社区维护的适配分支,也就是把Flutter引擎、Dart运行时和OpenHarmony的图形栈做了一层对接。对你我来说,这意味着两件事:第一,你写的还是标准Dart代码和Widget,生命周期、渲染树、状态管理这些概念不变;第二,平台相关的能力,比如文件路径、系统设置、剪贴板,不能想当然地假设每个插件都有OpenHarmony的实现。
选Flutter for OpenHarmony而不是用原生ArkTS重新写一套界面,核心收益是代码复用。如果团队里已经有一份成熟的Flutter业务代码,或者主力端是Android/iOS,那么迁移到OpenHarmony时,UI层和数据层可以原样带走,只需要补平台差异部分。反过来,如果你想面向OpenHarmony做一款纯新应用,又希望以后能低成本扩展到其他端,那Flutter同样是性价比很高的入口。这个记事本项目选它,也正是看中这条跨端路径足够干净。
做一个轻量级应用还有一个隐形的优势:依赖少。记事本这种工具型App,不依赖复杂地图、推送、支付SDK,正好适合拿来验证一个跨端框架的成熟度。把它放在OpenHarmony上,你能集中精力观察UI渲染、文本输入、文件IO这些基础能力靠不靠谱,而不被第三方SDK的适配问题干扰判断。
1.2 为什么拿记事本App练手
很多人觉得记事本太简单,不值得作为一个“实战”项目。但真把需求拆开看,一个正经的笔记编辑器要覆盖的东西一点都不少:文本输入与光标控制、自动保存、异步文件读写、列表数据刷新、时间排序、页面生命周期管理。这些恰好是大部分工具类应用都会遇到的公共难题。把记事本做扎实了,再去做待办清单、日记、知识卡片,基本就是换一层皮的事。
而且记事本的需求边界很清晰,适合用“最小闭环”来控制项目复杂度。我这个项目的目标功能就三条:能建笔记、能编辑笔记、能稳定保存并读回。在这个前提下,笔记编辑器成了整个项目的主战场。布局上不追求花哨,功能上不做云同步、不做富文本特效,目标是把编辑体验调顺,把数据链路跑通。
这样一个边界清晰的项目放在新平台上,还有一个好处:排查问题时变量少。如果用的是复杂业务,出了Bug你很难分清楚是业务逻辑问题、框架问题还是系统适配问题。记事本这种规模的项目,一旦跑不通,定位问题的路径会很短。
1.3 项目形态与技术栈总览
项目整体只包含两个页面:笔记列表页和笔记编辑页。列表页负责展示所有笔记的标题、摘要、更新时间,点击一条进入编辑页;编辑页顶部是标题输入框,中间是正文编辑区,下方有一个快捷工具栏,用来快速插入Markdown语法符号。
技术栈方面,我刻意控制在很小的范围内。UI框架用Flutter,本地存储直接用文件加JSON序列化,不引入数据库;状态管理用setState加一个单例存储服务,不引入重量级框架。最终包体小、依赖少,也更容易在适配不够完善的环境里跑起来。整条数据链路是:编辑器里的字符串进入Note模型,模型被JSON序列化,再落盘写入应用私有目录;读取时反向执行。
| 能力模块 | 实现方案 | 选型理由 |
|---|---|---|
| UI层 | Flutter Widget | 跨端复用,布局代码统一 |
| 状态管理 | setState + 服务单例 | 轻量,无额外依赖 |
| 本地存储 | JSON文件 | 可读性好,避免数据库适配风险 |
| 文本编辑 | TextField | 系统级输入体验,兼容性最佳 |
| 时间处理 | DateTime | 内置能力,足够满足排序需求 |
2. 环境搭建与工程初始化
2.1 搭建OpenHarmony侧开发环境
这部分是整个项目里最“劝退”但也最重要的一步。Flutter官方主线工具不一定直接认识OpenHarmony,所以你需要把以下几样东西准备齐,并且尽量让它们的版本互相匹配。
第一是OpenHarmony SDK。如果你手头有官方的开发板,建议直接按照开发板对应的SDK版本安装;如果没有实体设备,也可以用模拟器先跑通流程。第二是配套的IDE,创建OpenHarmony工程、配置签名、查看日志都靠它。第三是适配版Flutter SDK,也就是包含OpenHarmony平台支持的Flutter分支。装好之后在终端执行flutter --version能正常输出版本号,才算第一步通过。
这里最容易犯的错是混用版本。比如Flutter分支更新了,但OpenHarmony SDK还停留在旧版本,构建时就会出现莫名其妙的链接错误。我的建议是找一套已经被验证过组合的版本记录,按那个组合安装,不要全都追最新。版本匹配这件事,越是新平台越要保守。
装完三件套后,还要确认环境变量。需要让IDE和命令行工具都能找到OpenHarmony SDK,同时确认适配版Flutter SDK在PATH里排在前面。如果你机器上之前装过官方Flutter,这一步尤其容易踩坑,两个版本路径混在一起,命令工具经常会定位到错误的那一个。
2.2 创建Flutter工程并添加ohos平台
环境就绪后,创建工程的过程大体上还是Flutter那套。先运行工程初始化命令,让它生成一个标准的Dart项目结构,然后通过平台添加命令把OpenHarmony平台目录补进去。命令执行完成后,工程根目录下除了android、ios这些常规平台目录,会自动多出一个ohos目录,里面是OpenHarmony侧的原生工程骨架。
ohos目录里的东西,本质上是一个独立的OpenHarmony原生应用壳。它用ArkTS语言描述应用入口和页面载体,负责把Flutter引擎挂载进来。平时开发你基本不会手动改这里,但有两个文件需要关注:应用配置文件,用来声明权限、应用ID、版本号;还有模块配置文件,用来配置入口页面的路由。默认生成的模板一般都能跑,只有在你需要自定义应用名或权限时才需要动它们。
把这个目录弄明白很重要。很多人在Flutter侧写代码没问题,一到“怎么跑上设备”就卡住,根本原因是没搞懂Flutter工程和OpenHarmony工程之间的从属关系。简单说,Flutter侧只是产物,最终安装到设备上的、带原生页面壳的,是ohos目录单独构建出来的应用包。你在Flutter侧改完代码,要先把它编成Flutter产物,再由OpenHarmony原生工程打包成可安装的应用。
2.3 真机部署与签名
模拟器上跑通之后,想上真机,OpenHarmony和安卓一样也要求签名。如果没签名或签名不正确,安装时大概率会收到“无法安装”“校验失败”之类的提示。办理签名也是在配套IDE里操作,选择自动签名模式,登录开发者账号,让它生成密钥并写入工程配置。
这里有个经验:签名信息和工程的包名、证书指纹是绑定的,一旦改过包名,就得重新生成签名。我一开始图方便改了应用ID,结果真机装不上,来回排查了快一个小时才发现是签名失效。换完包名之后,把所有签名配置重新刷一遍,问题立刻消失。
另外要提醒的是,设备和电脑最好保持在同一个局域网内,IDE的调试工具链会更稳定。跑完一次真机构建后,如果只是想看UI改动,可以只改Flutter侧代码再增量构建,不用每次都重签原生工程,能省不少时间。
3. 笔记编辑器整体设计
3.1 编辑器形态选择:富文本还是Markdown
笔记编辑器的第一个分岔路,是究竟做“所见即所得”富文本,还是做“源码输入加预览”的Markdown模式。我最终选了后者,理由是它更契合“轻量级”三个字。
富文本编辑器听起来更强,但实际复杂度会迅速膨胀。光是加粗、斜体、下划线、字号、颜色这几个基础样式,就需要维护一套复杂的文本片段对象和选区操作逻辑,跨平台还会遇到输入法组合输入和前一个字符样式合并这类隐藏问题。在OpenHarmony这个适配还不够全面的环境里,富文本编辑风险更高,一个没有覆盖到的输入场景就可能让内容错乱。
Markdown方案的设计思路是:编辑器里始终是普通字符串,用户通过按键或者工具按钮插入**、*、#这类语法符号,保存时直接存原始字符串,展示时再解析成富文本。这样做的好处非常明显:数据是纯文本,格式简单,谁都能看懂;存储和传输完全不用考虑二进制兼容;即使将来不接解析库,也能回退成纯文本显示,绝对不会丢内容。所以我把编辑器的核心定位为“帮你高效产出Markdown文本的小工具”。
3.2 数据模型与本地存储方案
笔记数据模型我定义得非常简单,核心字段只有四个:唯一ID、标题、正文内容、更新时间。ID用来区分不同笔记,标题和正文分别来自编辑页上的两个输入框,更新时间既用于列表排序,也用于在编辑器底部显示“最近保存于xx:xx”的反馈。
存储层考虑过三个方案:SQLite数据库、SharedPreferences、JSON文件。SQLite很正规,但这需要额外适配sqlite插件在OpenHarmony上的实现,对轻量级项目来说偏重;SharedPreferences适合存少量配置,把一个几千字的笔记塞进去倒是能存,但既不优雅,也不方便迁移;JSON文件方案把全部笔记放在一个可读性非常高的文本文件里,读写逻辑用 Dart的File API就能完成,几乎不依赖第三方插件。
我最后选择JSON文件,还有一个原因是方便排查问题。笔记存成文件后,如果保存逻辑出了问题,可以打开文件看到底存了什么、哪一步写坏了。用数据库的话,想出问题现场还得额外拉数据库工具。对轻量项目来说,这种看得见的存储方式安全感更高。
3.3 页面结构与状态管理
编辑页在布局上拆成三块:顶部是返回按钮加标题输入框,中间是占满剩余空间的正文TextField,底部是横向滚动的快捷工具栏。正文区使用多行、无装饰、可自动换行的输入框,这样可以最大程度让用户聚焦在文字本身。
状态管理我直接用setState,没有引入Riverpod或Bloc这类框架。原因很直接:编辑器的状态本身并不复杂,无非是当前控制的文本内容、当前笔记对象、自动保存的定时器。用setState足以清晰表达这些状态的更新,而且一旦遇到状态更新问题,函数调用栈非常直观。引入重量级状态管理框架反倒会增加一层抽象,当平台上出现诡异渲染问题时,你会多一个需要排除的嫌疑点。
为了让业务代码更整洁,我把存储逻辑抽成了一个单例存储服务类,负责笔记的读取、保存、列表查询。Widget层只和这个服务打交道,不直接碰文件路径和JSON解析。这样列表页和编辑页都依赖同一个存储入口,数据和UI充分解耦,将来想换成数据库存储,只需要替换服务类内部实现。
4. 核心功能实现记录
4.1 笔记列表与编辑器初始化
列表页在进入时调用存储服务读取全部笔记,解析成Note对象数组,按更新时间倒序排列。加载过程我用FutureBuilder处理状态:等待时显示一个轻量占位,出错时给出提示和重试按钮,加载完成则渲染卡片列表。每张卡片只显示标题、正文首行的摘要和相对时间,摘要过长会被裁剪。
编辑器页面接收一个已有的Note对象作为参数。如果是新建状态,就生成一个带新ID和当前时间的空笔记;如果是编辑状态,则把传入笔记的标题和正文分别填进两个控制器。这一步的关键是初始化时机:必须在initState完成后、页面首次渲染前设置好控制器文本,否则用户会看到输入框里先闪一下空白再出现内容。
@override void initState() { super.initState(); _titleController = TextEditingController(text: widget.note.title); _contentController = TextEditingController(text: widget.note.content); _setupAutoSave(); }我习惯把新建和编辑统一成一条初始化路径,唯一区别是Note对象是否为空。这样做的好处是后续所有保存逻辑只需要面向“当前正在编辑的Note”这一个对象,不用在页面里维护一堆分支标记。
4.2 自动保存与退出落盘
笔记编辑器最核心的体验是“不用点保存也不丢内容”。实现方式是给正文控制器加一个监听器,内容一变就触发自动保存;但每次按键都写盘太重,所以要加防抖机制。
我的做法是准备一个定时器成员变量。监听器被触发时,先把上次没执行的定时器取消,再新建一个800毫秒后执行的保存任务。也就是说,用户连续打字时保存任务一直被推迟,直到停手超过800毫秒才真正写盘。如果你希望更稳,可以把间隔缩短到300毫秒;如果设备闪存较弱,可以放宽到1.5秒。
void _onContentChanged() { _saveTimer?.cancel(); _saveTimer = Timer(const Duration(milliseconds: 800), _persistNote); }自动保存并不能覆盖所有退出场景。用户编辑完直接按返回键,这时正文最后一次变化可能还没到防抖触发点,所以必须在页面销毁前的回调里做一次强制保存。我同时把返回拦截和系统回退都统一到这个强制保存流程中,确保离开页面时数据一定落盘。
落盘前,我会把当前标题和正文内容写回旧的Note对象,并更新时间为当前时间,然后再调用存储服务保存。标题输入框虽然没有单独加监听,但强制保存时统一读取控制器文本,所以标题和正文的保存节奏是一致的。
4.3 Markdown快捷输入工具栏
工具栏存在的意义,是让用户不用记忆Markdown语法也能写出格式。每个按钮做的事情都是同一类操作:在光标位置插入一段语法文本,然后把光标移动到合适的位置。我在页面底部放了一条横向按钮栏,常用的有标题、加粗、斜体、项目符号、引用、分隔线。
在Dart侧实现插入逻辑时,最核心的是TextEditingValue的选择区间。你需要读取当前的光标位置,在光标处拼入语法片段,然后重新设置控制器文本,并把新的光标坐标定位到用户最可能继续打字的位置。以加粗为例,如果光标在“笔记”两个字后面,插入结果就是**笔记**,光标落在两个星号之间,用户继续输入的文字会被包在加粗语法内。
void _insertMarkdown(String prefix, String suffix) { final text = _contentController.text; final selection = _contentController.selection; final start = selection.start; final end = selection.end; final selectedText = text.substring(start, end); final newText = text.replaceRange( start, end, '$prefix$selectedText$suffix'); _contentController.value = TextEditingValue( text: newText, selection: TextSelection.collapsed( offset: start + prefix.length + selectedText.length), ); }有选中文本时插入语法会把选中内容包起来,没有选中文本时则插入一对空语法占位符。这两个场景都覆盖后,工具按钮在手感和效率上基本就够用了。
4.4 最近更新排序与空状态处理
列表排序很简单,每次加载后按updatedAt字段从大到小排。这里有一点需要注意:保存时要真正更新更新时间字段,如果只是更新内容而忘了刷新时间,列表就会一直停留在旧顺序,用户会以为“保存没生效”。
空状态处理是很多人容易忽略的体验细节。新用户打开App,如果列表空空荡荡,至少要有清楚的新建入口和引导文案。我在空状态页放了“点击右下角按钮新建第一条笔记”的提示,并在列表页右下角放了一个醒目的浮动按钮。新建按钮点击后直接跳转编辑页,返回后刷新列表。
为了让列表在实际使用中更顺手,我还在每个卡片上加了左滑删除操作。删除动作会同步删除存储中对应的笔记数据,如果删除的是正在编辑的笔记,编辑器返回后列表也要能立即反映出来。这需要列表页在每次从编辑器返回时重新加载数据,而不是用缓存的状态。
5. 多端适配与细节打磨
5.1 中文输入、光标与软键盘遮挡
布局问题里,最常见的坑是软键盘弹出后遮挡正文输入区。Flutter默认的Scaffold在大部分场景会自动调整页面高度,但在OpenHarmony上表现不一定和Android完全一致,我遇到的典型情况是键盘弹起后底部工具栏被顶出屏幕。解决办法是为工具栏包一层可视区域自适应组件,让工具栏保持在键盘上方。
中文输入法还有另一个隐藏问题:组合输入过程中,监听器也会收到多次文本变更事件。如果自动保存逻辑没有做防抖,用户拼一个汉字就可能触发几次写盘。虽然最后结果没错,但会产生无谓的IO开销。所以我调整自动保存逻辑,让监听器里只做定时器刷新,不直接写盘,这样输入法再“话多”也只会被压缩成一次保存动作。
光标问题主要发生在插入Markdown语法后。如果代码里直接设置controller.text = newText,可能会导致光标跳到最末尾,用户体验非常割裂。解决方法是像我前面那段代码那样,使用TextEditingValue同时设置文本和光标位置。凡是涉及程序化修改文本的逻辑,都要把光标位置当成一等公民来维护。
5.2 平台通道的兜底方案
OpenHarmony的插件生态还在建设中,并不是所有Flutter插件都有对应实现。编辑器的文件读写,我优先尝试用社区适配过的路径插件来拿应用私有目录。如果某个目标环境没有适配这个插件,就要用更底层的平台通道方案兜底。
平台通道的思路很简单:Flutter侧通过MethodChannel发起一个方法调用,比如请求“返回应用文档目录”,OpenHarmony原生侧收到后,用ArkTS代码拿到沙箱目录路径,再返回给Flutter侧。这个方案的本质是把依赖降到零,不求插件,只靠Flutter自带的一对一通道能力。
const platform = MethodChannel('com.example.note_storage'); final String path = await platform.invokeMethod('getDocumentDir');原生侧需要做的就是在应用入口处注册这个通道的处理逻辑,并按方法名分发。用上这个方案后,哪怕市面上所有第三方插件都不支持OpenHarmony,应用最基础的文件读写依然能跑通。我的建议是:核心能力尽量用平台通道自己包一层,不要在一个刚起步的生态里把身家性命押在第三方插件上。
5.3 性能、包体与启动体验
轻量级记事本在性能上不需要做夸张优化,但有两个体验点值得关注。一是冷启动速度,二是输入卡顿。冷启动慢大多出在原生应用加载阶段,和Flutter没关系,你要做的是别在启动时同步做重活,比如完整加载大文件。我改成先加载笔记元数据,进入编辑页时才读取单个笔记的完整内容,列表首屏会快很多。
输入卡顿要特别注意。如果每敲一个字都把整篇笔记写入磁盘,设备再差一点,很快就会出现打字延迟。自动保存的防抖机制天然解决了这个问题。另一个容易忽略的点是JSON序列化和反序列化成本,笔记多的时候列表页会同步解析全部文件导致掉帧。我实测后把列表页改成了懒加载,只解析前几十条元数据,滚动到末尾时再补加载。
包体方面,这个项目刻意没引入额外依赖,总体积比常规Flutter应用小不少。如果你也做轻量级项目,建议每引入一个插件前都问一句:这个功能没有第三方依赖能做吗?很多时候答案是可以的,少一个插件就少一份适配风险。
6. 常见问题与排查清单
6.1 编译与依赖问题
最典型的报错是Flutter插件在某一步调用了OpenHarmony没有实现的原生接口,导致链接失败或运行崩溃。报错信息里通常会带有插件包名或原生类名,顺着它去查这个插件是否有OpenHarmony适配版本,没有的话就换方案:优先找功能等价且已适配的插件,找不到就退回平台通道自研。
还有一类编译报错来自缓存不一致。切换了适配版Flutter SDK后,旧的构建缓存还指向老引擎,命令执行会出现版本冲突。这类问题基本可以用清理工程目录下的临时构建文件、重新拉取依赖来解决。不要一上来就重装环境,大多数缓存冲突在项目里清理就够了。
版本匹配问题值得多说一句。OpenHarmony工具链更新比较频繁,某个版本的IDE配合某个版本的SDK是稳的,换了其中任何一个都可能引入不确定性。我建议把验证过的版本组合记录在项目文档里。升级前先在模拟器上跑一遍完整流程,确认没问题再改真机。
6.2 运行时问题
真机上最常见的运行时问题是应用一打开就闪退。排查顺序很重要:先看原生侧日志,确认是不是签名问题导致应用授权失败;再看Flutter侧日志,看有没有未捕获异常;最后看是不是缺少某平台权限。我曾经遇到过一次闪退,最后定位到是应用启动时尝试读取一个还不存在的目录,加了一个空判断就解决了。
文本输入方面,比较隐蔽的问题是中英文混合输入时,光标或组合文本偶尔会错乱。这个问题的根源多数出在自定义键盘逻辑或者毛刺的焦点处理上。我最后的处理方案是降低自定义程度,正文区使用原生TextField默认行为,不在编辑层叠加额外焦点管理,问题基本消失了。
列表刷新方面,如果从编辑器返回后列表没有更新,九成是因为列表页没有重新加载数据。不要把编辑结果直接改在内存里然后期待列表自动感知,最稳妥的方式,是在编辑器页面正常返回后,强制列表页重新走一遍数据加载流程。
6.3 问题速查表
| 表现 | 可能原因 | 处理建议 |
|---|---|---|
| 真机安装失败 | 签名失效或包名变更 | 重新生成并配置签名 |
| 冷启动白屏 | 原生启动时加载了过重资源 | 懒加载数据,延迟执行Flutter侧初始化 |
| 键盘遮挡编辑区 | 底部工具栏未跟随软键盘 | 使用可视区域自适应组件包裹工具栏 |
| 输入法触发频繁写盘 | 自动保存无防抖 | 监听器只刷新定时器,到期后才落盘 |
| 正文内容丢失 | 退出页面未强制保存 | 在返回拦截中同步落盘 |
| 列表顺序不更新 | 保存时未刷新更新时间 | 写入前统一更新Note对象的updatedAt |
| 插件运行报错 | 插件缺少OpenHarmony实现 | 替换为已适配插件或改用平台通道 |
最后再分享一个我在这个项目里最深的体会:在OpenHarmony上做Flutter开发,“少依赖”是最高优先级的设计原则。功能边界能画多小就画多小,插件能不用就不用,文件格式能纯文本就别弄二进制。这套原则让我在适配环境并不算完美的前提下,依然拿到了一个稳定可用的记事本App。如果你也想复刻这个项目,我建议从最基础的“存一条笔记再读回来”开始,先把数据链路跑通,再去填充Markdown工具栏那些锦上添花的功能。链路一旦通了,后面都只是积累细节和查表的事。