
1. 这次适配的背景HarmonyOS NEXT的底气与Flutter社区的补位1.1 为什么这件事非做不可我是在2024年底陆续接到几份鸿蒙适配需求单的。最开始大家的态度挺一致——先观望等华为和Flutter官方把路铺好再说。但等到HarmonyOS NEXT正式面向开发者开放不再兼容APK之后情况就变了如果你的Flutter应用还想出现在应用市场里不针对鸿蒙做一次真适配基本等于放弃华为终端的用户盘子。而实际动手之后你会发现Flutter本身跑上鸿蒙已经不是问题。OpenHarmony SIG组维护了flutter_flutter和flutter_engine的ohos分支社区里已经有可用的Flutter SDK for OpenHarmony也被称为FlutterOHOS。问题在于我们项目里依赖的那些纯Dart包还好说凡是用了原生能力的三方包基本都要重新过一遍。device_info_plus这种包在安卓iOS上很成熟但在鸿蒙上要么没有实现要么实现得不完整。所以platform_utils这个组件的适配才成了关键任务。它要解决的是跨平台开发里最基础也最容易被低估的一件事——设备特征感知当前设备是什么型号、系统版本多少、屏幕多大、电量多少、是不是平板、厂商是谁。这些信息在任何跨平台应用里都是地基。地基歪了上面做界面适配、做业务策略、做统计分析全都会跟着歪。这篇文章不打算去复述官方文档我尽量按照自己实际动过手的路径来写先讲组件架构再讲鸿蒙端适配的关键代码然后是排坑过程最后聊聊性能与稳定性。如果你也在给Flutter项目做鸿蒙适配里面大部分问题你一定躲不开。1.2 platform_utils是什么先说清边界再谈适配在往下聊之前我觉得有必要把platform_utils的边界定义清楚。社区里有不少叫这个名字的包我们项目里使用的是一个自研版本核心职责是统一入口对外只暴露一个PlatformUtils类不让你在业务代码里到处写if (Platform.isAndroid)这种脏判断。标准化输出无论底层是Android、iOS还是HarmonyOS最终返回给Dart层的是一个字段结构完全一致的Map上层永远只需要关心这套结构。按需扩展设备信息、系统信息、屏幕信息、电池信息每一个能力可以独立扩展不影响老接口。这套设计思路其实借鉴了Android系统里DeviceConfig的那种按Key获取配置的思想只是下沉到了平台层。适配鸿蒙时最难的不是写代码而是理解平台的差异点。鸿蒙NEXT虽然是全新的系统但它提供的API设计思路跟Android、iOS有很多相似之处比如都有获取设备型号的接口、都有获取屏幕宽高的接口。可是这些接口返回的语义、单位、字段名都不一样。platform_utils的价值就是把这些差异在内部消化掉对外保持稳定。2. platform_utils的架构拆解从一次设备信息请求到标准化输出2.1 Dart侧三件套PlatformInterface、MethodChannel、缓存我们组件在Dart层采用了Flutter官方推荐的PlatformInterface模式PlatformInterface定义抽象接口保证所有平台实现必须实现同一套方法。MethodChannel通过通道跟原生侧通信鸿蒙和安卓iOS一样支持这个机制。缓存层设备信息在运行期间基本不会变化请求过一次之后直接缓存避免每次调用都走一次异步通道。先看Dart侧的核心代码import package:flutter/services.dart; import package:flutter/foundation.dart; /// 平台信息标准化模型 class DeviceProfile { final String brand; final String model; final String systemName; final String systemVersion; final double screenWidthDp; final double screenHeightDp; final double physicalWidth; final double physicalHeight; final double pixelRatio; final bool isTablet; DeviceProfile({ required this.brand, required this.model, required this.systemName, required this.systemVersion, required this.screenWidthDp, required this.screenHeightDp, required this.physicalWidth, required this.physicalHeight, required this.pixelRatio, required this.isTablet, }); factory DeviceProfile.fromJson(MapString, dynamic json) { return DeviceProfile( brand: json[brand] as String? ?? , model: json[model] as String? ?? , systemName: json[systemName] as String? ?? unknown, systemVersion: json[systemVersion] as String? ?? , screenWidthDp: (json[screenWidthDp] as num?)?.toDouble() ?? 0, screenHeightDp: (json[screenHeightDp] as num?)?.toDouble() ?? 0, physicalWidth: (json[physicalWidth] as num?)?.toDouble() ?? 0, physicalHeight: (json[physicalHeight] as num?)?.toDouble() ?? 0, pixelRatio: (json[pixelRatio] as num?)?.toDouble() ?? 1.0, isTablet: json[isTablet] as bool? ?? false, ); } } class PlatformUtils { static final PlatformUtils instance PlatformUtils._(); static const MethodChannel _channel MethodChannel(com.example/platform_utils); DeviceProfile? _cachedProfile; bool _isLoading false; final ListCompletervoid _pendingLoaders []; PlatformUtils._(); /// 返回标准化设备信息内部带缓存 FutureDeviceProfile getDeviceProfile() async { if (_cachedProfile ! null) return _cachedProfile!; if (_isLoading) { // 并发请求复用同一个 Future final completer Completervoid(); _pendingLoaders.add(completer); await completer.future; return _cachedProfile!; } _isLoading true; try { final raw await _channel.invokeMethod(getDeviceProfile); final profile DeviceProfile.fromJson(MapString, dynamic.from(raw as Map)); _cachedProfile profile; return profile; } finally { _isLoading false; for (final c in _pendingLoaders) { c.complete(); } _pendingLoaders.clear(); } } }这里有个小设计值得跟新手强调一下为什么不用FutureBuilder在build里直接拿数据因为设备信息在绝大多数场景下是启动阶段就要用的比如初始化路由、判断布局模式、上报埋点。缓存在内存里后续所有调用都是瞬时返回这才符合基础设施的定位。2.2 鸿蒙侧数据源deviceInfo、bundleManager和屏幕窗口鸿蒙端的实现核心是拿到设备的原始信息然后按标准结构组装返回。HarmonyOS NEXT提供了几个关键KitBasicServicesKit里面有ohos.deviceInfo能拿到设备型号、品牌、系统版本等。ohos.app.ability.bundleManager能拿到应用自身的包信息包括版本号、应用名称。ohos.display或窗口相关接口能拿到屏幕宽度、高度、密度等属性。ArkTS侧我的核心实现思路是这样的先创建一个PlatformChannelHandlerimport { deviceInfo } from kit.BasicServicesKit; import { bundleManager } from kit.AbilityKit; import { display } from kit.ArkUI; const DEFAULT_BRAND unknown; export class PlatformUtilsHandler { handleGetDeviceProfile(): Recordstring, Object { // 设备基本信息 const brand deviceInfo.getBrand() || DEFAULT_BRAND; const model deviceInfo.getModel() || ; const osVersion deviceInfo.getFullVersion() || ; const osName deviceInfo.getOsFullName() || HarmonyOS; // 屏幕信息 const defaultDisplay display.getDefaultDisplaySync(); const density defaultDisplay.densityPixels || 1.0; const physicalWidth defaultDisplay.width; const physicalHeight defaultDisplay.height; // 如果从应用市场安装可能没有“开发者选项”做模拟 // 但本方法不需要任何用户授权 const isTablet this.detectTablet(physicalWidth, physicalHeight, density); return { brand: brand, model: model, systemName: osName, systemVersion: osVersion, screenWidthDp: physicalWidth / density, screenHeightDp: physicalHeight / density, physicalWidth: physicalWidth, physicalHeight: physicalHeight, pixelRatio: density, isTablet: isTablet, }; } private detectTablet(width: number, height: number, density: number): boolean { // 根据短边 vp 长度判断 const shortSide Math.min(width, height) / density; // 常见做法短边 600vp 视为平板类设备 return shortSide 600; } }这里有一个容易埋坑的点鸿蒙的display模块拿到的width和height在折叠屏或分屏场景下返回的是当前窗口大小不是物理屏幕大小。但一般做设备特征感知我们更关心的是能力基线所以用当前窗口问题也不大。只是要记住这是当前可用屏幕而不是绝对屏幕后续拿去做适配决策时要心知肚明。3. 适配实操把第一个MethodChannel跑通的完整记录3.1 环境准备选择Flutter ohos分支还是自建工程不同的Flutter项目接入鸿蒙的方式不一样。如果是从零开始推荐直接用flutter_flutter的ohos分支创建工程这样能省掉很多手工集成工作。但如果是既有项目大概率你已经有一套稳定的Android工程和Flutter多引擎接入方案这时候要做的是在现有鸿蒙工程里嵌入Flutter页面。我这次走的路线是保留原来的Android工程单独建一个HarmonyOS工程然后Flutter侧用既有产物对接。实际操作时环境变量这样配# 拉取支持ohos的Flutter SDK git clone -b ohos https://gitee.com/openharmony-sig/flutter_flutter.git # 配置环境 export PATH$PWD/flutter_flutter/bin:$PATH export FLUTTER_STORAGE_BASE_URLhttps://download.flutter-io.cn export PUB_HOSTED_URLhttps://pub.flutter-io.cn注意这后面的两个export。国内网络环境下不配置镜像源拉依赖会非常痛苦因为Flutter默认从storage.googleapis.com下引擎产物这个地址在CI或部分运营商的网络环境里很容易超时。尤其是鸿蒙分支的引擎产物路径恰好不在CDN上不设镜像几乎寸步难行。然后你的鸿蒙工程侧还需要用DevEco Studio打开配置SDK路径保证hdc命令可用。我之前写过一篇关于Linux下用hdc连接鸿蒙平板的笔记这次同样用上了很多模拟器方案在HarmonyOS NEXT上支持得还不太好真机调试才是最稳妥的路径。3.2 建一个最小可用的ohos插件工程先明确一点Flutter调用鸿蒙原生能力走的还是标准通道机制通道名称必须和Dart侧完全一致。在鸿蒙工程里你需要创建或找到ohos目录下的插件模块。我们项目用的结构是entry/src/main/ets/ ├── entryability/ │ ├── EntryAbility.ets ├── pages/ │ ├── Index.ets └── platformutils/ ├── PlatformUtilsPlugin.ets └── PlatformUtilsHandler.ets关键在PlatformUtilsPlugin.ets里注册方法通道import { MethodChannel, MethodCallHandler, MethodCall } from ohos/flutter/plugins; export class PlatformUtilsPlugin { private channel: MethodChannel; constructor(channel: MethodChannel) { this.channel channel; } static register(engine: FlutterEngine): void { const channel new MethodChannel(engine, com.example/platform_utils); // 和 Dart 端保持一致 const handler new PlatformUtilsHandler(); channel.setMethodCallHandler((call: MethodCall, result: MethodChannelResult) { if (call.method getDeviceProfile) { try { const data handler.handleGetDeviceProfile(); result.success(data); } catch (err) { result.error(DEVICE_INFO_ERROR, get device info failed: ${JSON.stringify(err)}, null); } return; } result.notImplemented(); }); channel.setMethodCallHandlerInternal(handler); } }这里面有一个在官方文档里很难找到的细节Channel必须要在engine准备好之后注册。我一开始是在EntryAbility的onCreate里直接FlutterEngine还没有实例化导致通道注册成功但Dart侧一直收到MissingPluginException。后来改成在onWindowStageCreated之后或engine launch完成回调中注册才稳定。3.3 Dart侧条件编译与接口收敛因为project还不是纯鸿蒙工程Android和iOS的原有实现还需要保留。Dart侧通过PlatformInterface的token机制来确保调用的是对应平台的实现class PlatformUtilsPlatform extends PlatformInterface { PlatformUtilsPlatform() : super(token: _token); static final Object _token Object(); static PlatformUtilsPlatform _instance MethodChannelPlatformUtils(); static set instance(PlatformUtilsPlatform instance) { PlatformInterface.verify(instance, _token); _instance instance; } }然后在根main里根据kIsWeb和目标平台判断该拿哪个实现bool get isHarmonyOS { return !kIsWeb defaultTargetPlatform TargetPlatform.android; }这里有个陷阱必须提醒Flutter框架目前还没有一个正式的TargetPlatform.harmonyOS枚举。所以在默认情况下鸿蒙环境里Flutter会认为是Android平台因为是兼容OpenHarmony的映射关系Platform.isAndroid是true。这就会导致你如果完全依赖Platform.isAndroid去走Android原生实现在鸿蒙上会直接崩——因为Android那边用到了不存在的channel或者依赖了Android SDK的类。正确的做法是不要依赖Platform.isAndroid而是自己维护一个编译平台标记// 建议放在 pubspec.yaml 的 --dart-define 里 const bool kIsOhos bool.fromEnvironment(OHOS, defaultValue: false); FutureDeviceProfile getDeviceProfile() async { if (kIsOhos) { return PlatformUtils.instance.getDeviceProfile(); } return LegacyAndroidUtils().getDeviceProfile(); }在鸿蒙工程的构建脚本里注入--dart-defineOHOStrue其他平台不注入这样就走到了鸿蒙原生通道而不是误闯Android实现。4. 踩坑实录系统版本、虚拟像素单位与权限模型的理解差异4.1 厂商字段的大小写与空值问题设备信息这类数据最烦的不是拿不到而是拿到了格式不统一。隔壁安卓端device_info_plus在部分国产ROM上会把brand返回成空字符串鸿蒙这里反而比较稳定但大小写规则需要注意华为部分机型deviceInfo.getBrand()返回的是HUAWEI部分早期版本返回的是huawei。如果上层埋点系统对brand有大写约束就会出现一堆未知厂商的脏数据。我们内部的标准化规则是所有文本型字段统一trimbrand统一转大写model保留原始大小写systemVersion统一转成SemVer风格。这些逻辑全部放进platform_utils内部业务层拿到的一定是干净值。4.2 虚拟像素vp不是dp屏幕尺寸换算的连环坑这个坑几乎每个从Android转过来的团队都会踩。Android习惯用dp来描述布局鸿蒙有自己的描述单位叫做vpvirtual pixel。先看数据来源。在鸿蒙侧display模块返回的width和height单位是物理像素pxdensityPixels从数值上看跟Android的density很接近但细究起来不是一回事。Android的density 160dpi基准下的比例而HarmonyOS屏幕的densityPixels在实际设备上会受系统显示大小设置影响。也就是说用户如果在鸿蒙手机里调整了显示字体和屏幕缩放你拿到的densityPixels会跟着变进而影响用px/density算出来的vp宽度。这一点跟Android的dp行为有些微差异Android在部分系统下也会响应显示大小设置但鸿蒙的粒度更明显。我当时适配横竖屏布局时就是直接用了px除以density来近似vp。结果在默认设置下没问题一旦用户开启更大显示模式界面会有一小部分元素被挤出安全区。后来改成从显示属性里读取真实缩放阈值// 可选的修正思路 const display display.getDefaultDisplaySync(); const density display.densityPixels ?? 1.0; // 虚拟像素修正系数建议后续接入系统级的scaledDensity const scaledDensity display.scaledDensity ?? density; const widthVp display.width / scaledDensity;scaledDensity这个字段在API 12之后比较稳定能更准确地反映用户的显示缩放偏好。如果你的项目里已经有类似是否需要开启老年人模式的判断这个字段可以帮你少写很多猜测代码。4.3 权限模型HarmonyOS NEXT不再惯性兼容这个问题如果不说很多人会拿Android的思维往鸿蒙上套。HarmonyOS NEXT的权限模型分为system_grant和user_grant两种system_grant权限只需要在module.json5里声明安装时自动授予user_grant权限必须在运行时通过弹窗动态申请。platform_utils里的多数设备信息读取都是system_grant甚至不需要权限。但如果你要读电池信息、读取精确位置这类能力就必须走user_grant流程。更麻烦的是鸿蒙的动态申请是异步回调式的一次性方法直接返回不了授权结果。import { abilityAccessCtrl } from kit.AbilityKit; import { BusinessError } from kit.BasicServicesKit; function requestUserGrant(permission: string): Promisevoid { const atManager abilityAccessCtrl.createAtManager(); return new Promise((resolve, reject) { atManager.requestPermissionsFromUser?.(context, [permission]) .then((result) { const grantStatus result.authResults[0]; if (grantStatus 0) { resolve(); } else { reject(new Error(PERMISSION_DENIED)); } }) .catch((err: BusinessError) reject(err)); }); }我把这段逻辑封装成了一个独立的PermissionServiceplatform_utils在需要时调用它。注意requestPermissionsFromUser在低版本API里不是所有类都有兼容写法要判断atManager.requestPermissionsFromUser是否存在否则直接走旧版的requestPermissions回调。4.4 异步回调里的一次性陷阱MethodChannel的handler在鸿蒙端有一个容易被忽略的问题如果一次method调用被标记为result.success()之后你后续再去调用result.success()或result.error()会直接抛异常甚至导致通道阻塞。这在执行复杂任务时尤其危险——比如一次获取多组数据其中一组失败错误处理逻辑里可能会重复回调。正确的处理方式是一开始就做分流if (call.method getDeviceProfile) { // 只回调一次后续结果都放返回值里 try { const data handler.handleGetDeviceProfile(); result.success(data); } catch (err) { result.error(DEVICE_INFO_ERROR, ..., null); } return; }然后确保handler内部不做任何异步回调把异步逻辑全部收敛成同步阻塞设备信息基本都是低频、短耗时读取阻塞几个毫秒没问题。如果真是耗时操作推荐拆成一个单独的任务通道或者在Dart侧改用EventChannel来推送结果。5. 性能与稳定性缓存策略、降级方案和数据验证5.1 缓存到底要不要做我的回答是必须做但不能无脑做。设备信息在绝大多数App生命周期内是不变的但有些字段例外比如当前窗口尺寸、当前屏幕亮度、当前电池状态。如果简单粗暴地全部缓存折叠屏或平板切换成平行视界的时候就会拿到过期的尺寸数据。所以我在platform_utils内部把缓存分成两层稳定层brand、model、systemVersion、pixelRatio。动态层screenWidthDp、screenHeightDp、isTablet、batteryLevel。动态层每次调用都实时走MethodChannel但这个通道调用在鸿蒙端有一些额外开销不能像本地getter那样随便用。一个折中的办法是短时缓存动态层数据可以缓存5秒到10秒。比如用户旋转屏幕或者窗口变化后数据会在下一次请求时自动更新同时大部分高频调用命中的是缓存性能体感会好很多。const DYNAMIC_CACHE_TTL_MS 5000; let cachedDynamic: Recordstring, Object | null null; let cachedDynamicAt 0; export function getCachedDynamicInfo(): Recordstring, Object | null { const now Date.now(); if (cachedDynamic now - cachedDynamicAt DYNAMIC_CACHE_TTL_MS) { return cachedDynamic; } return null; }这种做法的好处是像首页这种一进来就要判断当前设备是不是折叠屏大屏模式的场景不会一帧内触发多次通道调用。5.2 异常降级不让一个设备信息错误拖垮整个页面既然platform_utils是基础设施就必须考虑基础设施挂掉的情况。我在集成测试里最常遇到的问题是鸿蒙真机没有打开开发者模式时部分调试通道调用会返回空数据或者异常尤其是display模块在低内存环境下偶发失败。我的降级逻辑是三层优先走MethodChannel获取完整数据。如果通道调用抛异常则降级到Dart侧的缓存如果存在。缓存也没有时返回一个内置的设备基线默认值保证页面不白屏。FutureDeviceProfile safeGetDeviceProfile() async { try { return await getDeviceProfile(); } catch (e) { if (_cachedProfile ! null) return _cachedProfile!; return DeviceProfile( brand: unknown, model: unknown, systemName: unknown, systemVersion: 0.0.0, screenWidthDp: 360, screenHeightDp: 640, physicalWidth: 720, physicalHeight: 1280, pixelRatio: 2.0, isTablet: false, ); } }这个默认值不是拍脑袋定的。360x640 dp是绝大多数手机的最短边基线目的只是让代码能继续跑不产生更严重的连锁错误。真正的业务逻辑在缺失信息时应该主动走通用分支而不是依赖这个默认值做精准判断。5.3 数据一致性验证真机遍历比想象中重要鸿蒙的OEM设备非常多光华为自家就有手机、平板、折叠屏、智慧屏多种品类还有大量OpenHarmony生态伙伴的设备。同一个API在不同设备上的返回差异比安卓还要大。我的建议是建立一份真机字段映射表每次适配完至少在一台手机、一台平板上回归一遍字段华为手机P系列华为平板鸿蒙模拟器getBrand()HUAWEIHUAWEIunknown或空getModel()具体型号具体型号空getFullVersion()5.0.0.xxx同上5.0.0.xdisplay.width12602560模拟器分辨率densityPixels3.02.02.75有几个模拟器的字段返回是空的不能代表真实设备这也是我坚持必须真机调试的原因。模拟器更适合流程验证不适合数据准确性验收。6. 推广到全场景设备特征感知的标准化对业务开发意味着什么6.1 把设备判断逻辑从业务层清除出去完成了platform_utils的鸿蒙适配最大的收益不是能跑了而是我们的业务层从此不用再关心当前跑在哪个系统上。以App首页为例之前代码里散布着大量这样的逻辑if (Platform.isAndroid) { // 安卓平板判断 } else if (Platform.isIOS) { // iPad判断 }鸿蒙出来以后这套代码直接失效。因为Flutter识别鸿蒙为Android这样判断出的平板根本不准。适配之后业务层只认我们的标准模型final profile await PlatformUtils.instance.getDeviceProfile(); if (profile.isTablet) { // 走多栏布局 } else { // 走单栏布局 }isTablet这个字段是platform_utils在不同平台上分别用Android最小宽度逻辑、iOS用户界面习惯、鸿蒙短边vp阈值各自算出来的但业务侧看到的永远是同一个布尔值。这就是标准化的意义平台差异关在组件里业务侧只管业务。6.2 后续规划清单这次适配做完之后我整理了一份后续规划你也可以参考补充更多能力当前只有设备特征、屏幕、基础系统信息后续还需要把电池状态、网络状态、应用版本号统一进来把platform_utils做成真正的系统属性提取层。接入CI自动化鸿蒙构建在CI里跑通后增加模拟器拉取 真机远程调用的自动冒烟脚本。因为MethodChannel这类通道在编译期发现不了拼接错误必须跑起来才能暴露。社区包替代方案持续关注device_info_plus官方对鸿蒙的支持进度一旦官方稳定实现覆盖够了再评估是否把自研实现迁移过去减少自维护成本。从技术选型的角度看Flutter这种跨平台方案在鸿蒙生态里会越来越被需要。手机、平板、折叠屏、车机屏幕、智慧屏几乎每个设备形态都需要一套识别设备、按设备适配的基础能力。platform_utils的鸿蒙适配只是第一步但它把后面所有业务适配的路都铺平了。最后说一个我个人的体会适配鸿蒙这件事技术难度其实没有想象中那么高真正的难点在于不能带着安卓的经验直接搬。无论是vp和dp的换算还是user_grant权限的动态申请体验都需要你重新站在鸿蒙系统的用户视角理解一遍。把这些理解沉淀进组件里你的App在鸿蒙生态里的体验才会真正立得住。