最近我把手头一个Flutter商城项目往OpenHarmony上平移,原以为只是换一个编译目标的事,结果从SDK版本、依赖库到生命周期管理,每一步都有新坑。这篇记录以下我实际跑通的实现过程,适合有Flutter基础、想评估OpenHarmony跨端方案的开发者,也适合商城类业务的技术负责人做个参考。
我尽量把“为什么这么做”也写清楚,不光是粘贴代码。你如果也在做“Flutter for OpenHarmony”类似的事情,把这篇当作一份经验备忘录就行。
1. 项目背景与整体设计思路
1.1 为什么选择 Flutter 来适配 OpenHarmony
OpenHarmony 是一款开源的、面向全场景的操作系统,应用生态和工具链还在快速完善期。作为应用开发者,不可能每个平台都用完全独立的原生代码重写一遍,跨端方案就成了必然选择。
我曾经评估过两个方向:一个是用 ArkUI 从零开发,另一个是继续用 Flutter 保留现有业务逻辑。考虑到团队已经积累了一套完整的 Flutter 商品展示、购物车、订单的代码,重写成本实在太高,所以最终定了“业务代码保留,平台适配重写”的策略。
Flutter 的优势在于它的自绘渲染引擎,不依赖系统原生控件,这跟 OpenHarmony 的 ArkUI 框架在底层渲染上没有直接冲突。理论上只要 Flutter 引擎能在 OpenHarmony 设备上跑起来,上层 UI 就能保持一致。另一个隐性好处是 Dart 语言本身的 AOT 编译产物比较小,对性能要求较高的商城首页滚动、图片列表这类场景相对友好。
实际动手之后我的体会是:Flutter 生态里大量依赖都是纯 Dart 实现的,比如状态管理、路由、网络请求的封装,这部分可以直接迁移;但凡是依赖原生能力的插件,比如支付、安全存储、系统分享,几乎都需要重新写 OpenHarmony 侧的适配。这一点必须在项目启动前就明确传达给团队,否则后面一定会被各种 Native 插件卡住。
1.2 商城 App 的功能边界与目标
做商城 App 最怕“什么功能都想要”,所以在适配 OpenHarmony 的实战中,我先把功能范围切成了一个最小可行闭环:
- 账号体系:登录、Token 刷新、退出登录
- 首页:Banner、金刚区、推荐商品位
- 商品列表:分类筛选、分页加载、下拉刷新
- 商品详情:SKU 选择、加购物车、立即购买
- 购物车:全选/反选、数量修改、价格汇总
- 订单流程:确认订单页、提交订单、模拟支付
- 订单列表:分页展示、订单状态流转
我只把“真实支付”放在了模拟层,因为 OpenHarmony 上的支付通道跟 Android 完全不同,涉及很多业务资质和硬件环境限制,不适合放进一个纯技术实战项目。订单状态机和支付回调解耦后,后面接真实通道只是替换一个实现类的事。
这次实战的目标也定得比较实际:首屏能在 2 秒内完成展示,列表滚动不掉帧,连续浏览商品 30 分钟不崩溃。以这个目标为底线,我不会为了追求视觉效果而过度引入复杂依赖。
1.3 架构设计:把“平台相关”关进笼子
跨端适配最大的风险,是业务代码里到处散落着if (Platform.isAndroid)或者直接的 MethodChannel 调用。到最后你会发现应用永远跑不起来,因为每一步都在跟平台细节纠缠。
所以我定了一条硬性规则:除了仓库层代码可以接触平台通道,其他任何页面、状态、模型一律不感知自己跑在什么系统上。
整个分层大概是这样的:
- 表现层:页面、组件、路由,只依赖抽象接口
- 业务层:购物车、订单、登录的 UseCase/Service
- 数据层:Repository 接口 + 实现
- 平台通道层:MethodChannel 调用、原生能力封装
举例来说,购物车页面需要读取 Token,它只依赖AuthRepository.getToken(),但不会关心 Token 是来自 shared_preferences、系统账号体系还是文件中转。真正到实现层时,再用一个OhosAuthRepository去调通道。
这套设计带来的直接好处是,我可以在没有真机的纯 Dart 环境下,用 Mock 数据先把商城页面全部跑通。后面再接 OpenHarmony 通道时,只需要替换 Repository 的实现,页面零修改。这也是这次项目能在一周内跑通的核心原因。
2. 环境搭建与工程配置
2.1 3步完成 Flutter 开发 OpenHarmony 的环境准备
第一步不是写代码,而是把开发环境调成可复现状态。我建议在项目根目录写一个SETUP.md,把下面的命令和版本手工记录进去,避免组里每个人环境不一致。
我当时用的版本范围是:Flutter 3.10.x 分支、OpenHarmony SDK 4.x 开发者预览版、JDK 17。OpenHarmony 的 Flutter 支持不是标准 Flutter SDK 自带的,而是需要额外的引擎适配包。这一步最容易踩坑,因为不同版本的 Flutter 对应不同的 OpenHarmony 引擎,版本对不上就会在编译阶段报各种奇怪的链接错误。
操作流程分三步:
- 安装 IDE,并在 SDK Manager 中勾选 OpenHarmony SDK、工具链、模拟器镜像。
- 拉取 Flutter SDK 后,切换到适配 OpenHarmony 的分支或补丁版本,并设置环境变量
FLUTTER_ROOT、OPENHARMONY_HOME。 - 项目目录下执行
flutter doctor检查基础环境,不过 flutter doctor 不会识别 OpenHarmony 环境,还需要手动检查ohpm -v和hvigorw -v是否可用。
这里分享一个我个人的习惯:永远不要直接改全局 Flutter SDK,而是在项目里放一个.tool-versions文件,用类似 asdf 的工具管理 Flutter 和 JDK 版本。这个项目适配过程中我至少切换过三次 Flutter 小版本,没有版本管理早就乱了。
2.2 创建工程目录并接入 OpenHarmony 平台
标准flutter create生成的项目目录里通常只有android、ios、web等平台目录,OpenHarmony 不会被直接识别。所以需要在工程根目录手动创建一个ohos目录,并在里面维护 OpenHarmony 侧的工程文件。
我当时用的是社区插件生成的模板,核心目录长这样:
ohos/ ├── AppScope/ │ └── app.json5 ├── entry/ │ ├── src/main/ │ │ ├── ets/ │ │ │ ├── MainAbility.ets │ │ │ └── pages/ │ │ └── resources/ │ └── build-profile.json5 └── build-profile.json5entry/build-profile.json5里需要显式指定runtimeOS为OpenHarmony,并配置签名和模块名。这里要特别留意targetSdkVersion,我一开始照抄了 Android 的 32,结果 OpenHarmony 侧编译直接报错,后来改成 API 10 才通过。
接入 OpenHarmony 目录后,还需要把 Flutter 引擎的 so 库和资源文件放到entry/libs或通过依赖引用方式接入。具体路径和 SDK 版本强相关,建议直接看官方模板,不要自己手写 CMake,因为在 OpenHarmony 上 Flutter 是通过预编译引擎包运行的,不是从源码构建。
2.3 依赖兼容性排查:能用纯 Dart 就别引原生插件
商城应用几乎离不开网络、图片缓存、路由、状态管理这些基础库。我在项目里走了一圈,发现最大的限制不在 Flutter 层,而在“原生插件是否有 OpenHarmony 实现”。
整理一份我实际评估过的依赖应对清单:
| 功能 | 常用方案 | OpenHarmony 适配判断 |
|---|---|---|
| 网络请求 | dio | 底层是 dart:io,基本可用,但要重新验证 TLS/证书策略 |
| 状态管理 | provider / riverpod | 纯 Dart,直接用 |
| 路由 | go_router | 纯 Dart,直接用,但要注意深链与原生跳转的桥接 |
| 本地存储 | shared_preferences | 需要原生实现,我换成了针对 OpenHarmony 的自研通道 |
| 图片加载 | cached_network_image | 文件缓存逻辑可用,但解码底层要测试,我做了自适应封装 |
| 二维码 | 常见原生插件 | 没有现成实现,暂时砍掉,改为服务端生成 |
我的原则是:业务层完全不依赖任何原生插件,所有平台能力都收敛在 Repository 接口后面。这样即使某个插件不支持,影响面也只是一个实现类,而不是整个页面。
3. 商城核心模块实现细节
3.1 网络层与数据层:让 Mock 数据先跑通
商城类应用最核心的数据流就是“请求商品信息 -> 拉取商品列表 -> 下订单”。为了保证不依赖后端环境,我先把数据层定义成接口,再分别写一个MockDataSource和一个ApiDataSource。
ApiClient 的封装用了 dio,简单示例如下:
class ApiClient { ApiClient({required this.baseUrl, required this.tokenProvider}); final String baseUrl; final Future<String?> Function() tokenProvider; late final Dio _dio = Dio(BaseOptions( baseUrl: baseUrl, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), )); void init() { _dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) async { final token = await tokenProvider(); if (token != null && token.isNotEmpty) { options.headers['Authorization'] = 'Bearer $token'; } handler.next(options); }, onError: (e, handler) async { // 统一处理 401 刷新 Token 的逻辑 handler.next(e); }, )); } Future<GoodsDetail> fetchGoodsDetail(String goodsId) async { final resp = await _dio.get<Map<String, dynamic>>('/goods/$goodsId'); return GoodsDetail.fromJson(resp.data!); } }这个封装本身不涉及任何平台差异,但有一个细节在 OpenHarmony 上会遇到:开发阶段我们连的是局域网本地调试服务,而 OpenHarmony 模拟器对明文 HTTP 请求默认是拦截的。你需要通过网络安全配置的方式临时允许特定域名,或者直接用 https 和自签证书处理。
我的做法是写了一个NetworkSecurityConfig的适配声明,只放行开发环境域名,正式包直接走 https。这一点不提前处理,你会看到页面数据一直加载失败,但断点又打不进去,容易被误导成 DIO 本身不兼容。
3.2 商品列表页与图片加载的优化
商城首页和列表页是性能敏感区域。我用CustomScrollView + SliverGrid搭建了商品瀑布流,避免使用 Column + ListView 嵌套导致的双重滚动。
核心结构如下:
CustomScrollView( slivers: [ SliverToBoxAdapter(child: HomeBanner()), SliverPadding( padding: EdgeInsets.all(8), sliver: SliverGrid( delegate: SliverChildBuilderDelegate( (context, index) => GoodsCard( goods: goodsList[index], onTap: () => context.push('/detail', extra: goodsList[index]), ), childCount: goodsList.length, ), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, mainAxisSpacing: 8, crossAxisSpacing: 8, childAspectRatio: 0.68, ), ), ), ], )页面简单,但图片加载才是真正的坑。Flutter 默认的Image.network在 OpenHarmony 上并不是完全不可用,而是容易出现并发连接数过高、缓存失效的问题。我在这个项目里封装了一个GoodsImage组件,先用图片文件缓存路径判断,再回退到内存缓存和网络加载。
封装组件时要注意:不要自己实现一套图片三级缓存,太耗时。先把cached_network_image跑一遍,如果发现兼容问题,再改用“先用原生侧下载到临时目录,然后用Image.file展示”的方案,这个方案在 OpenHarmony 上非常稳定。
另外一定要给商品卡片加上RepaintBoundary,减少列表滚动时的重绘范围。这个优化在 Android 上可能感觉不明显,但在 OpenHarmony 模拟器上,不加的话滚动时会有一阵一阵的卡顿感。这些看似很小的调整,对商城体验的影响却很大。
3.3 购物车状态管理与全选计算
购物车是一个典型的跨页面共享状态场景:商品详情页加购、购物车页修改数量、底部导航栏显示角标,至少三个页面需要同步。所以我从一开始就使用了ChangeNotifier + Provider,而不是在每个页面里用setState各自维护。
下面是购物车模型的核心实现片段:
class CartModel extends ChangeNotifier { final Map<String, CartItem> _items = {}; int get totalCount => _items.values.fold(0, (sum, item) => sum + item.count); double get totalPrice => _items.values .where((item) => item.selected) .fold(0.0, (sum, item) => sum + item.price * item.count); bool get isAllSelected => _items.isNotEmpty && _items.values.every((item) => item.selected); void toggleSelection(String goodsId) { final item = _items[goodsId]; if (item == null) return; item.selected = !item.selected; notifyListeners(); } void toggleAll(bool selected) { for (final item in _items.values) { item.selected = selected; } notifyListeners(); } void addItem(Goods goods, int count) { final existing = _items[goods.id]; if (existing != null) { existing.count += count; } else { _items[goods.id] = CartItem(goods: goods, count: count, selected: true); } notifyListeners(); } }这里我特别强调了selected字段,因为很多新手实现购物车时会把选中状态放在 UI 层,结果一刷新选中状态就乱了。把选中状态放进模型,总价、全选才能保持一致性。这是商城项目里很基础但很重要的逻辑。
用Provider监听时,商品详情页只需要context.read<CartModel>().addItem(...),购物车页面用context.watch<CartModel>()重建小计区域,不需要整个页面刷掉。这里推荐结合Selector只监听totalPrice,否则数量角标和价格区域的小改动会触发整个页面的 rebuild,滚动时难免卡。
3.4 订单生成与模拟支付
订单流程比购物车复杂一些,因为涉及跨页数据传递和支付结果回传。我采用了“先在后端生成订单号,再进入支付页”的流程。在模拟环境下,后端直接返回一个假订单号,前端拿到后在支付页等待 1 秒,再模拟回调成功。
这里没有把支付按钮直接接到 MethodChannel 上,而是定义了一个PaymentService:
abstract class PaymentService { Future<PayResult> startPayment(PaymentRequest request); } class MockPaymentService implements PaymentService { @override Future<PayResult> startPayment(PaymentRequest request) async { await Future.delayed(const Duration(milliseconds: 800)); return PayResult.success(orderId: request.orderId); } }这样做的价值在于:后面接真实 OpenHarmony 支付时,新写一个OhosPaymentService替换到依赖注入容器里即可,订单页面根本不用动。我后来就把这个 MockService 换成了通过 MethodChannel 调起原生的支付页面,整个过程只改了容器注册那几行。
订单结束后需要跳转到订单列表页,并且刷新订单状态。这块我也建议通过状态管理统一广播,比如一个OrderUpdatedEvent,而不是让订单列表页面在返回时重新请求,否则会看到页面闪一下。
4. 平台通道与 OpenHarmony 原生能力集成
4.1 MethodChannel 的 Dart 端与 ArkTS 端写法
商城项目真正触到 OpenHarmony 原生能力的地方,主要是登录、支付确认、安全存储和系统分享。Flutter 与原生通信我用的是 MethodChannel,这几乎是所有跨端框架的标准方案。
Dart 端封装一个统一的通道入口:
static const _channel = MethodChannel('com.example.shop/pay'); Future<bool> requestPay(String orderId, double amount) async { final bool? ok = await _channel.invokeMethod<bool>('pay', { 'orderId': orderId, 'amount': amount, }); return ok ?? false; }ArkTS 端在 MainAbility 里创建同名通道,并注册处理器:
import { MethodChannel } from "@ohos/abilityAccessCtrl"; const channel = new MethodChannel(context, "com.example.shop/pay"); channel.setMethodCallHandler(async (call) => { if (call.method === "pay") { const orderId = call.arguments["orderId"]; const amount = call.arguments["amount"]; // 这里可以调起原生支付确认页 const result = await doPay(orderId, amount); return result; } });这里有一个很容易忽略的线程问题:MethodChannel 的回调默认运行在主线程上,如果直接在处理器里做耗时操作,原生侧的 UI 会被卡住。我在实现时把耗时逻辑丢到 taskpool 里,等任务完成后通过 Promise 返回结果。
另外通道名必须完全一致,Dart 端和 ArkTS 端一字不差。我本来以为这是常识,结果排查了半天发现有一次把包名带到了 channel name 里,导致通道注册成功但一直收不到回调。
4.2 登录态与 Token 的安全管理
商城的 Token 不能明文放到普通文件里,Android 上我会用 Keystore 或者 EncryptedSharedPreferences,OpenHarmony 上没有对应插件,我就通过 MethodChannel 调一下系统级的安全存储能力。
Dart 侧只暴露一个接口:
class SecureTokenStorage { Future<void> saveToken(String token) async { await _channel.invokeMethod('saveSecureData', {'key': 'access_token', 'value': token}); } Future<String?> readToken() async { final value = await _channel.invokeMethod('readSecureData', {'key': 'access_token'}); return value as String?; } }ArkTS 侧则调用系统提供的加密存储接口。这样做有一个临时风险:模拟器上系统安全存储可能不可用,所以我额外写了一个降级方案,把 Token 用 AES 加密后再写入普通文件。注意,这个降级方案只用于开发预览,正式环境必须走系统安全存储。
登录流程本身也需要注意一个细节:第三方登录授权回调往往会带一个临时 code,商城业务需要拿着 code 去后端换 Token,而不是直接信任前端传回的 Token。这个流程在 OpenHarmony 上最重要,因为不同设备的 WebView 和账号体系差异很大,直接把原始 Token 给前端的做法风险太高。
4.3 生命周期:Flutter 容器如何跟随页面
OpenHarmony 的应用托管方式跟 Android Activity 不一样,Flutter 容器是作为 Ability 的一部分运行的。如果原生页面跳到了 Flutter 容器上,然后 Flutter 容器退到后台,引擎并不会自动暂停渲染,这会导致 CPU 和电量消耗上升。
我在实现时通过原生侧在onBackground和onForeground回调里主动通知 Flutter 引擎:
- 进入后台:调用
FlutterView.pause或者通知 Dart 侧执行SystemChannels.lifecycle.setApplicationState(...) - 回到前台:恢复渲染
在 Dart 侧,我也监听了生命周期相关通道,用于暂停不必要的网络请求和轮询。比如商品详情页的倒计时秒杀组件,在页面退到后台时会立刻暂停计时器,否则十分钟后回到页面时会发现倒计时跳了很远,还会出现内存泄漏警告。
还有一个坑是页面路由栈。商城应用通常会从原生 Tab 页面跳转到 Flutter 商品详情页,再从商品详情页跳转到 Flutter 订单页,如果使用 push 的时候没有处理好路由表,按返回键时可能会出现直接退出应用的现象。我的做法是让原生侧和 Flutter 侧共享一个路由常量表,所有跨端页面跳转都通过同一个openRoute(routeName, params)通道来完成,避免两边各自维护一套路由表。
5. 常见问题与坑点排查
5.1 编译期问题速查
我在这个项目里遇到的编译错误,大多数都不是代码问题,而是环境配置和 SDK 版本不匹配。整理了一张速查表,希望对你有用:
| 报错特征 | 可能原因 | 处理方式 |
|---|---|---|
| Could not resolve class | OpenHarmony SDK 版本与 Flutter 适配包不匹配 | 对齐 Flutter 引擎包和 SDK API 版本 |
| CMake Error: ASM compiler not set | 缺少原生工具链 | 重新安装 IDE 的 NDK/工具链组件 |
| Invalid signature | 签名配置缺失或过期 | 重新生成签名并更新 build-profile.json5 |
| hvigor failed to load plugins | hvigor 版本不兼容 | 按 IDE 提示升级 hvigor 到指定版本 |
| AAPT2 not found | 资源编译器路径无效 | 清理 .gradle 缓存并重置 SDK Manager |
遇到编译问题时,我的经验是先看是否与 OpenHarmony 的版本强绑定。社区适配包经常会依赖某个 OpenHarmony 版本的私有头文件,升级系统 SDK 后,原本可用的引擎库会突然链接失败。最快的解决方式不是自己改编译脚本,而是把 Flutter 引擎替换成跟当前 SDK 配套的预编译版本。
还有一点,任何修改 OpenHarmony 侧原生代码的操作,都可能触发增量编译不完整的问题。我习惯在有疑问时先执行一遍ohos侧的 clean 任务再重新编译,否则会看到一些莫名其妙的旧缓存报错。
5.2 运行时崩溃与内存泄漏
商城应用在低端设备上最容易出的问题是内存不足。Flutter 的图片缓存默认比较大,而 OpenHarmony 模拟器的可用内存比预期小很多。我在连续浏览商品详情页 20 分钟后,应用直接闪退,日志里可以看到 OOM 相关的异常。
针对这个情况,我做了三个调整:
- 全局设置
PaintingBinding.instance.imageCache.maximumSize = 80;,限制图片缓存数量。 - 商品列表切换到详情页前,释放列表页的大量图片引用。
- 在原生侧排查是否每个 FlutterView 都被正常关闭,避免跳转多个页面后创建多个 Flutter 容器实例。
运行时崩溃还有一个隐蔽来源:MethodChannel 的返回值类型不一致。Dart 侧期望bool,ArkTS 侧返回了字符串,通道会直接抛类型转换异常,而且不会在 Flutter 侧留下明显堆栈。后来我在通道封装里统一用标准 JSON 结构返回,例如{"code": 0, "data": true},再在 Dart 侧做类型转换,问题一下好排查多了。
5.3 性能调优:首帧和滚动流畅度
首帧速度直接影响商城用户的第一印象。OpenHarmony 模拟器上,Flutter 应用冷启动首帧比 Android 慢不少,我实测本地构建版本大概在 1.8 到 2.5 秒之间,按我之前定的 2 秒目标还是有一点紧张。
优化方式主要有三个方向:
- 减少首帧前的同步初始化工作,把没有依赖的 Repository 全部改成语法上延迟初始化。
- 首页 Banner 和金刚区不阻塞主渲染流程,等首帧结束后再异步加载。
- 商品列表使用
ListView.builder或SliverGrid,确保只构建可见区域。
滚动流畅度方面,我用 DevTools 里的性能分析抓了一次帧渲染时间,发现商品卡片上有一个阴影特效占了很大开销。把阴影改成浅色描边后,平均帧耗时从 16ms 降到了 9ms。很多时候卡顿不是 Flutter 引擎的问题,而是你像素级的 UI 装饰太多了。
如果你在 OpenHarmony 上遇到列表滚动一卡一卡的情况,可以先尝试开启RepaintBoundary和禁用溢出阴影,这两个操作比盲目升级引擎版本有效得多。
经过这一轮实战,整个商城 App 已经能在 OpenHarmony 模拟器和真机上稳定跑通核心闭环。如果让我重新再来一遍,我不会一上来就对齐所有页面,而是先跑通一个不接任何原生能力的纯 Flutter 最小页面,然后逐步叠加商城逻辑和平台通道。这样每一步的失败原因都清晰可控。
最后再分享一个小技巧:在适配阶段把 OpenHarmony 相关代码全部收敛到一个独立的适配库中,不要和业务代码混在一起。这个适配库可以单独维护版本号,业务层永远依赖抽象接口。我就是靠这个方式,在切换了三次 SDK 版本的情况下,页面代码几乎没怎么改动。跨端适配从来不是“能不能做到”,而是“值不值得做到”,这一套框架下去,至少在 OpenHarmony 生态里可以走得更远。