1. 在 OpenHarmony 上做本地持久化,和 Android 有什么不一样
做 Flutter 的人第一次把项目迁到 OpenHarmony 时,最先炸的往往不是 UI,而是存储。你打开 pub.dev 找shared_preferences,照着安卓文档写完,一跑,E/flutter 一串未处理异常抛出来,原因用一句话说:Flutter 官方 SDK 没有 OpenHarmony 平台,那些插件的原生实现跑不到 ArkTS 层上。
这件事其实不难理解。Flutter 自带的插件体系,在安卓上是通过 Kotlin/Java 写的 MethodChannel 来落地的,在 iOS 上是 Swift/Objective-C。OpenHarmony 既不是安卓也不是 iOS,所以 pub.dev 上的海量插件就分成两类:一类是纯 Dart 实现,比如 Hive;另一类带原生代码,必须找人改一套 OpenHarmony 版本,这就是为什么你会在搜索里看到shared_preferences_ohos、hive_ohos、drift_ohos、path_provider_ohos这类带_ohos后缀的包。名字没什么玄机,就是“换了底层实现”的意思。
到 2026 年,这个生态已经从 2023 年那种“能编译就算成功”的状态,走到了“普通应用可以正常落地”的程度。OpenHarmony 官方和第三方社区维护的 flutter SDK 分支已经很成熟,DevEco Studio 侧的工程模板也支持把 Flutter 模块嵌进 HAP 里统一编译。存储这个环节,三个方案各有人用:shared_preferences_ohos 承担轻量配置,Hive 承担对象与接口缓存,drift 负责关系型数据。本文就是把三条路各走一遍,说清它们各自的适用边界、接入方式和绕不开的坑。
适合读这篇文章的人有两类:一是准备把现有 Flutter 应用往 OpenHarmony 设备上迁移的开发者;二是正在选鸿蒙应用本地缓存方案,不知道用轻量 KV 还是上 SQLite 的决策者。下面所有内容都从实际编译和运行出发,不是拿文档抄一遍。OpenHarmony 的本地持久化,最终逃不开三个问题:数据多大、要不要查询、要不要迁移。
2. 环境准备:把 Flutter 编译到 OpenHarmony 的基础设施
2.1 环境清单与版本选择
Flutter 编译到 OpenHarmony,本质上还是一条“Flutter 工程 + 原生宿主工程”的路子,只是原生宿主从 Android 换成了 OpenHarmony 的 HAP 工程。你至少需要准备下面这些:
- DevEco Studio 5.x 及以上,内部自带 OpenHarmony SDK
- OpenHarmony SDK,主流 API 版本是 API 10/12
- Flutter 的 OpenHarmony 适配 SDK,通常来自 openharmony_flutter 的仓库,分 stable 和 dev 分支
- JDK 17,用于编译 HAP 的配套工具链
- Node.js 和 hvigor 命令行工具
- 一台真机或者模拟器,通过 hdc 连接
环境变量的配法,不同仓库要求不太一样,但共同点是把 flutter 的二进制路径和 OpenHarmony SDK 路径指到显眼位置。一般在 shell 里这样加:
export PATH=/opt/flutter_ohos/bin:$PATH export DEVECO_SDK_HOME=/opt/DevEco-Studio/sdk export OHOS_SDK_HOME=/opt/ohos-sdk export NODE_HOME=/usr/local/node配完之后跑一句flutter doctor -v,看到 OpenHarmony 相关的检查项亮绿灯,基本就通了。有个细节是,开发机上可能同时装了安卓 SDK 和 OpenHarmony SDK,flutter 命令会默认去找 Android,这时候要用flutter config或者环境变量强制指定目标平台,否则编译的时候还会以安卓工程的逻辑找 Gradle。
2.2 工程结构:Flutter 模块与 ohos 宿主
OpenHarmony 的 Flutter 工程,不会像安卓那样自动生成一个 android 目录就够了。你要手动创建或者用模板生成一个ohos宿主目录,里面是标准的 OpenHarmony 工程:entry 模块、oh-package.json5、hvigorfile.ts 这些。最终产物是 HAP 包,不是 APK。
典型的结构长这样:
my_app/ ├── lib/ # Flutter 业务代码 ├── ohos/ │ ├── entry/ # OpenHarmony 宿主模块 │ ├── build-profile.json5 │ └── hvigorfile.ts ├── pubspec.yaml └── flutter_module/把 Flutter 当成一个 ohos 模块去集成,原理上很像安卓那边的 Flutter AAR 方案。只是名字不叫 AAR,而是“把 flutter 产物打进 HAP 的 arkui 层”。第一次接触的人最容易懵的是:明明 flutter 代码在 lib 下写好了,但编译的时候不是 flutter build 一下就行,而是要去 ohos 目录下执行 hvigorw 任务。
我实际跑下来,最省事的流程是:
cd ohos hvigorw assembleHap这个命令会自动触发 Flutter 侧的构建,把 Dart 代码编译成 libflutter.so 需要的产物,再打包装进 HAP。如果项目里加了 swiper、camera 这类带原生视图的插件,还需要单独处理插件在 ohos 侧的注册文件,否则编译能过,运行到视图创建时会白屏或报错。
2.3 新建项目跑不起来的常见原因
“flutter 新建项目后跑不起来”几乎是每个人都会遇到的第一步坑。我之前排查过不少次,真正高频的原因就四个:
- 设备没连上:hdc 不是 adb,必须先执行
hdc list targets,能列出设备编号再谈安装。很多新手一直用 adb,当然装不上 HAP。 - 签名没配置:OpenHarmony 真机调试默认要求签名,没签名时 hvigorw 安装阶段直接拒绝。需要在 DevEco 里配置调试证书,或者用
--automatic-signature之类参数完成自动签名。 - HAP 版本和设备 API 不匹配:比如 SDK 编出来是 API 12,真机是 API 10,安装时会报 INSTALL_PARSE_FAILED。
- Flutter 没走 ohos 编译路径:Flutter 命令还在按安卓逻辑找 Gradle 和 Java,导致构建产物缺了 OpenHarmony 需要的 arkui 壳。
这个阶段我建议不要贪多,先跑通一个最小工程,确认flutter run能在 OpenHarmony 真机上看到 hello world,再做存储选型。本地持久化相关的调试,如果连基础的工程链路都不稳,后面的报错会分不清是插件问题还是宿主问题。
3. 方案一:shared_preferences_ohos,轻量 KV 存储
3.1 接入与典型用法
shared_preferences_ohos是官方 shared_preferences 在 OpenHarmony 侧的适配,接口几乎一致。它底层调用的是 OpenHarmony 自带的偏好数据组件,也就是@ohos.data.preferences,最终落盘位置在应用沙箱下的 shared_prefs 目录,以 KV 文件形式存在。接入非常简单:
dependencies: shared_preferences_ohos: ^1.2.0代码写起来和安卓版没区别:
import 'package:shared_preferences_ohos/shared_preferences_ohos.dart'; final prefs = await SharedPreferences.getInstance(); await prefs.setString('access_token', 'abcdef'); await prefs.setInt('login_count', 1); String? token = prefs.getString('access_token'); int? count = prefs.getInt('login_count'); await prefs.remove('access_token');这个方案的特点就是“快、简单、够用”。不需要初始化文件路径,不需要建表,不需要写迁移逻辑。我通常在项目里用来存这几个东西:用户 token、登录态、引导页是否看过、UI 主题、语言设置、设备 ID。甚至一些不常变的低频配置,比如上次拉取广告配置的时间戳,也可以丢进去。
3.2 使用边界与坑
shared_preferences 不是万能箱,坚持了一个通用经验:单个 key 对应的 value 不要超过几百 KB。虽然 OpenHarmony 版本没有像 iOS 那边把 NSUserDefaults 卡得很死,但存大量业务数据会带来两个问题:一是每次 getInstance 都要读整个文件,体积一大,启动阻塞特别明显;二是写入频繁时,OpenHarmony 底层的偏好组件会做整文件回写,太多次 setString 会把启动时间拖垮。
有几个坑是你照着文档写时根本不会注意到的:
- 不要直接存复杂对象。你可以把一个 List
- 同一个 key 不要并发调用。getInstance 之后连续 setString 同一 key,偶尔会出现后写入的值覆盖前一个值。本质上这是异步时序问题,在 Flutter 单线程里也存在。
- shared_preferences_ohos 的初始化在纯 Dart isolate 里可能会找不到通道。如果在 isolate 里调用,先用 WidgetsFlutterBinding.ensureInitialized() 占住平台通道。
最佳实践是把 shared_preferences 封装成一个 SettingsRepository,对外只暴露读写方法,方便以后从 KV 迁到 SQLite。封装的意义不是多写两层代码,而是给选型留后路。
4. 方案二:Hive,纯 Dart 的对象缓存
4.1 接入、开盒与对象存储
Hive 是纯 Dart 实现的本地 KV 数据库,不依赖原生代码,理论上天然适配 OpenHarmony。实际接入时要用hive_ohos来做文件路径适配,或者你单独用path_provider_ohos拿到应用文档目录后自己初始化 Hive。
依赖长这样:
dependencies: hive: ^2.2.3 hive_ohos: ^2.1.0 dev_dependencies: hive_generator: ^2.0.1 build_runner: ^2.4.0初始化代码:
WidgetsFlutterBinding.ensureInitialized(); final dir = await getApplicationDocumentsDirectory(); Hive.init(dir.path); final box = await Hive.openBox<Map>('feed_cache'); await box.put('last_page_3', ['item1', 'item2', 'item3']); final list = box.get('last_page_3');Hive 的优势在于不用把对象手动转 JSON 字符串。只要你写一个 adapter,整个类可以直接放进去。定义一个用户实体:
@HiveType(typeId: 0) class User { @HiveField(0) final String name; @HiveField(1) final int age; User({required this.name, required this.age}); }然后跑一下代码生成:
dart run build_runner build --delete-conflicting-outputs再把 typeAdapters 注册进去:
Hive.registerAdapter(UserAdapter());之后就能:
final box = await Hive.openBox<User>('users'); await box.put('user_1', User(name: '张三', age: 18)); User? u = box.get('user_1');这套能力在缓存接口数据时非常舒服。Feed 流、商品列表、页面组件的中间态,都能以对象形态直接落盘,读出来也是对象,不用在自己做序列化。Hive 读速度快,主要是因为它会把数据块按 key 索引,读某条不扫全量。
4.2 版本迁移与数据文件维护
Hive 的迁移能力比 SQLite 弱,这是必须要接受的事实。升级一个类型的新字段,需要重新生成 adapter,并且旧盒子里的对象会因为缺少字段报错。比较稳妥的做法是,每次改 HiveType 字段时,把所有用到该类型的 key 换一个新的前缀,比如user_v1升级成user_v2,让老数据自然废弃,而不是强行覆盖。数据量小的时候,这种策略简单可靠;数据量大就不推荐了,这时候应该考虑 drift。
另一个实际问题是碎片化。频繁 put 和 delete 后,Hive 盒子文件不会把空洞自动回收,文件可能越用越大,这就跟搜索热词里“hive 优化小文件”说的是一个方向。排查时可以用box.compact()或box.shrink()手动压缩,但需要注意只能在没有打开其他帧的情况下调用。我通常的做法是:如果缓存盒子超过 20MB,就备份关键数据后直接删文件重建,而不是纠结碎片问题。
4.3 与 Provider 组合做缓存模块
Hive 非常适合和 Provider 一起做状态缓存。比如做网约车大数据之类项目的列表页,下拉刷新后把结果写入 Hive,下次冷启动先读缓存,再请求远端。配合 Provider 时,我会把 Hive 的读取包装成一个 repository,再在 ViewModel 里暴露。
我这里写一个简化的代码:
class FeedRepository { final Box<Map> _box; FeedRepository(this._box); List<dynamic> readPage(int page) { return _box.get('page_$page') ?? []; } Future<void> writePage(int page, List<dynamic> data) async { await _box.put('page_$page', data); } } class FeedProvider extends ChangeNotifier { final FeedRepository _repo; int _page = 1; List<dynamic> _items = []; LoadStatus _status = LoadStatus.idle; Future<void> refresh() async { final remote = await Api.fetch(_page); await _repo.writePage(_page, remote); _items = remote; notifyListeners(); } Future<void> loadFromCache() async { _items = _repo.readPage(_page); _status = LoadStatus.loadingCache; notifyListeners(); } }这里的关键是,Provider 只和 repository 打交道,不关心 repository 底层是 Hive 还是 shared_preferences。以后要换 drift,页面层一行代码都不用改。
4.4 什么时候别再选 Hive
Hive 不是万能的。遇到下面这些情况,明显它不合适:
- 需要联表查询。比如订单主表和订单明细表,Hive 做不到一次查询关联两张盒子。
- 数据量达到百万级。Hive 的 key-value 模型对大数据不友好,内存里做筛选太慢。
- 需要复杂索引和模糊搜索。Hive 只能按 key 精确取,想实现 LIKE 查询只能自己遍历。
- 多端数据同步。SQLite 有成熟的事务和导出格式,Hive 文件的跨端兼容性很差。
我见过很多项目一开始用 Hive 存用户消息列表,觉得挺顺手,后来越存越多,开始出现“列表加载卡顿、内存抖动”才发现 Hive 撑不住,最后迁到 drift。所以选 Hive 前,先想清楚你的数据会不会长大、会不会长出关系模型。
5. 方案三:drift + SQLite,正规的关系型数据库
5.1 依赖、建表与初始化
drift 是 Flutter 生态里最成熟的 SQLite 封装,底层就是原生 SQLite。OpenHarmony 上要额外用drift_ohos包,把数据库文件的路径映射到 OpenHarmony 沙箱,并绑定系统的 sqlite 库。
dependencies: drift: ^2.16.1 drift_ohos: ^2.1.0 dev_dependencies: drift_dev: ^2.16.1 build_runner: ^2.4.0建表用 Dart 类描述,代码看起来像这样:
class Todos extends Table { IntColumn get id => integer().autoIncrement()(); TextColumn get title => text().withLength(min: 1, max: 255)(); BoolColumn get completed => boolean().withDefault(const Constant(false))(); } @DriftDatabase(tables: [Todos]) class AppDatabase extends _$AppDatabase { AppDatabase() : super(_openConnection()); @override int get schemaVersion => 1; @override MigrationStrategy get migration => MigrationStrategy( onCreate: (m) async { await m.createAll(); }, ); }连接函数是 drift 在 OpenHarmony 侧的关键差异点。不能直接用 sqflite 的 getDatabasesPath,也不能默认漂移会帮你建文件。正确做法是:
QueryExecutor _openConnection() { return OhosQueryExecutor('app_database.db'); }这套代码生成之后,会在 build 目录里生成app_database.g.dart,你需要导入它来获得$TodosTable这些内部类。写查询时,drift 会自动转成 SQL,不需要手写。
5.2 查询、批量写入与事务
drift 的优势在于强类型查询和原生 SQL 能力。你可以写这样的查询:
Future<List<Todo>> getUncompleted() { return (select(todos) ..where((t) => t.completed.equals(false)) ..orderBy([(t) => t.id])) .get(); }也可以直接用 SQL 片段处理复杂统计:
final count = await customSelect( 'SELECT COUNT(*) as c FROM todos WHERE completed = ?', variables: [Variable(false)], ).getSingle();批量写数据时,务必用事务包裹。十万条数据逐条 insert 大概要几百毫秒,包进 batch 后能压到一百毫秒内。drift 提供了 batch 操作:
Future<void> batchInsert(List<Todo> items) async { await transaction((_) async { await batch((b) { b.insertAll(todos, items); }); }); }性能优化方面,最简单也最有效的一招是加索引。你在 Table 类里对经常作为 WHERE 条件的字段调用.indexed(),比如用户表按 token 查询:
class Users extends Table { TextColumn get id => text().primaryKey(); TextColumn get token => text().unique().indexed()(); IntColumn get lastActive => integer().indexed()(); }加了索引之后,十万条数据按 token 查,耗时通常在几十毫秒内,完全够用。真正慢是慢在没索引时全表扫描。
5.3 schema 迁移与版本管理
数据库最怕的是后面加字段忘了改版本。drift 把迁移放在 MigrationStrategy 里,你只需要提升 schemaVersion,再写 upFrom 逻辑:
@override int get schemaVersion => 2; @override MigrationStrategy get migration => MigrationStrategy( onCreate: (m) async { await m.createAll(); }, onUpgrade: (m, from, to) async { if (from == 1) { await m.addColumn(todos, todos.completed); } }, );这里有个容易踩的坑:建表时如果字段用了withDefault,在 ON CONFLICT 和迁移时都要考虑默认值。从老表提升版本时,SQLite 对已有行添加 NOT NULL 字段非常敏感,一定要给默认值,否则迁移直接失败。
搜索热词里有“sqlite 修改字段的类型”,这个问题本质上是一个乌龙:SQLite 是动态类型,你定义 INTEGER 列,也可以塞 TEXT,ALTER TABLE 改类型也不报错,但实际存储行为可能不符合预期。遇到“改了字段类型但查询还是老结果”,多半是数据库文件里旧行的 type 没变,需要重建表而不是单纯 alter。
6. 三种方案横向对比和选型决策
6.1 多维度对比
把三个方案放在同一张表里看,选型逻辑会清晰很多:
| 维度 | shared_preferences_ohos | Hive | drift(SQLite) |
|---|---|---|---|
| 数据模型 | 键值对 | 键值对+对象 | 关系型表 |
| 原生依赖 | 依赖 ohos 偏好组件 | 无,纯 Dart | 依赖 sqlite3 |
| 学习成本 | 低 | 低 | 中 |
| 复杂查询 | 不支持 | 不支持 | 支持 |
| 事务支持 | 弱 | 弱 | 强 |
| 迁移能力 | 无 | 手动 | schemaVersion 迁移 |
| 大数据量 | 不推荐 | 不推荐 | 推荐 |
| 跨端调试 | 简单 | 简单 | 可用 DB Browser |
| 典型用途 | 配置、token | 接口缓存、对象缓存 | 业务数据、报表、本地搜索 |
从这张表能看出来,三个方案不是完全替代关系,更像“轻量到重量”的三档选择。shared_preferences 管配置,Hive 管缓存,drift 管业务。
6.2 按业务直接对号入座
拿实际的 OpenHarmony 应用场景来举例。如果做一个带相机的工具类应用,用户设置、拍照参数、最近编辑记录,这些数据量很小且结构固定,直接 shared_preferences_ohos 就够了,没必要上 drift,省掉一整套构建代码。如果是消息流应用,离线缓存聊天记录或者资讯列表,数据结构是“列表+对象”,用 Hive 最舒服,读取快、易维护。如果做一个本地记账应用,要统计收支、按月份筛选、多表关联,这种无论如何都要选 drift。本地搜索、报表、排序这类需求,用 KV 存储是自找麻烦。
我比较推荐的默认策略是:先想清楚数据的生命周期。一次性读取的配置用 KV;需要长期累积、跨版本演进的数据用 SQLite;只有接口缓存这种“删了也无所谓”的数据,才适合放 Hive。很多项目最终的问题都不是某个库不够好,而是把缓存数据当业务数据存,或者把业务数据塞进 KV,导致后面迁移成本高到不敢动。
7. 实操现场:典型报错与数据调试
7.1 常见报错速查表
把我在 OpenHarmony 上跑这三个方案遇到的高频问题整理成了速查表:
| 报错现象 | 可能原因 | 处理方式 |
|---|---|---|
E/flutter 出现DartVMInitializer unhandled exception | 插件在 ohos 侧未注册,或 MethodChannel 调用时宿主没接管 | 检查 ohos 工程里是否有对应 plugin 的注册声明,重新构建 HAP |
| 安装 HAP 失败,提示 signature error | 调试证书没配置或过期 | 在 DevEco 里重新生成调试证书 |
| Hive 打开盒子后数据是旧格式 | adapter 变更后没有重新生成代码 | 执行dart run build_runner build,并清理旧盒子数据 |
| drift 建表时字段类型不匹配 | SQLite 动态类型,历史脏数据捣乱 | 导出 db 文件,用 DB Browser 查看实际列类型 |
| 数据库查询慢,十万条要等几秒 | 缺索引,全表扫描 | 对 where 字段加.indexed(),必要时看执行计划 |
| OpenHarmony 设备上找不到 db 文件 | 文件在应用沙箱,普通文件管理器看不到 | 用 hdc file recv 拉到 PC 上检查 |
| 启动白屏或渲染异常 | 可能是 impeller 渲染与旧设备兼容问题 | 先关闭 impeller 渲染参数跑一遍,排除渲染原因 |
这里面最容易被忽略的是环境问题。2026 年,OpenHarmony 的 Flutter 生态虽然跑通了,但插件注册机制和安卓还是差别很大。pubspec.yaml里加了依赖,并不能保证原生模块被自动打包进 HAP。编译成功和运行成功是两回事,运行报错 80% 出在原生插件没有真正注册进来。遇到 E/flutter 插件通道错误时,先去 ohos 工程的 plugin 配置里找,别直接改 Dart 代码。
7.2 数据库文件导出与 DB Browser
排查 SQLite 问题时,最直观的办法就是把 db 文件拉到电脑上,用 DB Browser for SQLite 打开看。OpenHarmony 沙箱路径比较深,通常是:
/data/app/el2/100/base/<包名>/ef/database/<数据库名>.db用 hdc 连接后:
hdc shell "find /data/app -name 'app_database.db' 2>/dev/null" hdc file recv /data/app/el2/100/base/com.example.app/ef/database/app_database.db ~/Desktop/app_database.db拿到文件后,在 DB Browser 里能看到表结构、索引和每条记录。这个工具对排查“字段没建对、索引没生效、数据写入重复”都有帮助。数据库文件越小、字段越简单,越适合直接人工检查。我排查问题时,经常先确认 db 文件存在,再确认表结构对不对,最后才看业务数据。
7.3 一些性能小技巧
本地持久化在不同设备上表现差异很大,OpenHarmony 真机和模拟器差距明显。我给一个通用的性能原则:写库操作全部放在事务里,一次写比多次写快;读库操作不该放到 UI 线程的 build 里。drift 本身就支持把查询放到后台 isolate,Dart 代码里配合compute也能减少卡顿。
另外一个很实际的经验是,不要把本地存储当成远程服务的缓存层,然后所有页面都先读缓存。缓存命中率高还好,如果缓存命中率低,每次写入都白折腾。我会在 repository 里加一层 TTL 判断,数据过期了才读远端并回写本地,读缓存时若发现过期直接清掉。这样 Hive 文件不会无限膨胀,shared_preferences 也不会被大量无效值占满。
再说一句关于“flutter 和别的前端框架的优缺点”的问题。做跨端技术选型时,往往只盯渲染性能和组件生态,却忽略了数据层适配成本。OpenHarmony 上 Flutter 能用的插件就这些,如果业务里大量依赖 sqlite、sembast、isar 这类存储方案,就得提前确认有没有 ohos 版本。我在项目选型阶段会专门列一个表,把核心存储依赖的 ohos 适配状态写清楚,再决定要不要继续用 Flutter,这比后发现存不了数据再换框架要省太多事。
7.4 多应用沙箱与权限隔离的提醒
OpenHarmony 的应用沙箱比安卓更严格。调试过程中,你可能会遇到“明明 db 文件创建了,但换个签名重新安装后数据全没了”的情况。这不是写库失败,而是系统在卸载重装或签名变化时清掉了应用沙箱。如果是开发期测试,这种清空很正常,不用慌。但如果生产环境出现数据丢失,就要检查是不是签名切换导致 sandbox 变更。别把测试设备上的本地数据当回事,重要数据一定要有远端同步机制。
8. 我个人会用完一整轮后的建议
三个方案我都在 OpenHarmony 真机上跑过一轮,最终留下的印象是:shared_preferences_ohos 像螺丝刀,Hive 像瑞士军刀,drift 像工具箱。螺丝刀解决不了的问题,瑞士军刀也未必解决得了,但你不会因为有了工具箱就只用它拧螺丝。
如果是我现在接一个新的 OpenHarmony Flutter 项目,我会这样做:配置类用 shared_preferences_ohos,接口缓存和简单对象缓存用 Hive,一旦需求里出现筛选、排序、联表、搜索、多版本迁移这五个关键词里的任何一个,直接上 drift,不犹豫。至于 Hive 和 drift 之间怎么过渡,我会在 repository 层做统一接口封装,让页面拿到的永远是 Future 或 Stream ,而不是某个数据库专属类型。这样后期切换,只是换一个 repository 的接缝。
最后分享一个小技巧:不管选哪套方案,都要对 open 顺序保持敬畏。Flutter 启动早期就去读写数据库,很容易碰上 engine 尚未初始化或首帧阻塞。我习惯把存储初始化放到异步启动引导里,先确保 UI 首帧出来了,再回调去预热数据库连接。这个细节不会让你写代码时察觉,但在冷启动体验上差距非常明显。资深和菜鸟的区别,往往就藏在这些不起眼的执行顺序里。