1. 一个看似简单的空状态组件,为什么值得单独开一篇文章
做 Flutter 鸿蒙应用开发的人应该都有这种体会:网上搜"鸿蒙 Flutter 适配",翻来覆去就是环境搭建、工程配置、打包签名那几板斧,一旦进入实际业务开发,尤其是做那些不起眼但高频出现的基础组件时,基本只能靠自己踩坑。空状态组件就是这么个典型——它不复杂,但恰恰因为不复杂,很多人都是随手写个 Column 塞个 Image 和 Text 就完事了,等真跑到鸿蒙设备上一看,问题全来了。
我做鸿蒙版应用的时候,第一个被测试打回来的 bug 就是空状态组件:列表没数据时,页面底部被手势条挡住了一块、中文字体渲染偏大导致文字换行、页面切换回来后空状态居然自己消失了。这几个问题单个拎出来都不难修,但它们凑在一起,倒逼我把这个"最简单"的组件从头设计了一遍。这篇就围绕空状态组件的跨平台实现,把我从设计思路到鸿蒙真机适配、再到性能优化的完整过程写下来,代码可以直接抄,坑已经替你们踩过了。
先说清楚这个组件要解决什么问题。空状态在移动应用里至少覆盖三种场景:首次加载(还没请求数据)、无数据(请求成功但结果是空的)、加载失败(网络异常或服务端报错)。三种场景的 UI 表达、交互逻辑、埋点诉求都不一样,但绝大多数项目里它们共用一套视觉规范。所以空状态组件的核心职责,是用一套统一的接口把这些差异化场景收敛起来,让业务方不用每次重复写布局和样式。
提示:这篇文章的实践基于 Flutter 稳定版 + HarmonyOS NEXT 的 Flutter SDK 分支,Dart 版本 3.x。如果你还在用老的 OpenHarmony 3.x 那套环境,部分渲染行为会有差异,但设计思路是通用的。
下面先说整体架构,再逐层展开实现和踩坑过程。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 空状态组件的基础骨架:把"没有数据"拆成三种可维护的形态
2.1 先定接口,再写实现
很多人在封装组件时习惯先写 build 方法,接口设计是顺手加的,这导致组件越改越拧巴。我的习惯是先把接口定死,再倒推实现,因为接口决定了业务方的使用成本。
空状态组件的使用方是业务页面,他们的诉求就三句话:告诉我现在是什么状态、给我要显示的文案和图、我可能要自定义按钮。对应到接口上:
dart复制/// 空状态组件的状态类型
enum EmptyStateType {
loading, // 首次加载中
empty, // 无数据
error, // 加载失败
}
/// 空状态组件配置
class EmptyStateConfig {
final String title;
final String? description;
final String? iconKey; // 图标资源key,由组件内部映射到具体资源
final Widget? action; // 自定义操作按钮,如"重试"
final VoidCallback? onRetry;
const EmptyStateConfig({
required this.title,
this.description,
this.iconKey,
this.action,
this.onRetry,
});
}
/// 核心组件
class EmptyStateWidget extends StatelessWidget {
final EmptyStateType type;
final EmptyStateConfig config;
const EmptyStateWidget({
super.key,
required this.type,
required this.config,
});
}
这里有个关键设计:type 和 config 分开传。type 决定的是行为语义(要不要显示加载动画、失败时按钮文案是什么),config 决定的是具体内容。这样业务方可以这么做:
dart复制// 业务页面里的用法
EmptyStateWidget(
type: _stateType,
config: EmptyStateConfig(
title: _stateType == EmptyStateType.error ? '加载失败' : '暂无数据',
description: _stateType == EmptyStateType.error ? '请检查网络后重试' : '下拉刷新试试',
iconKey: _stateType == EmptyStateType.error ? 'ic_network_error' : 'ic_empty_box',
action: _stateType == EmptyStateType.error
? TextButton(onPressed: _loadData, child: const Text('重试'))
: null,
),
)
type 如果和 config 的内容矛盾,以 config 为准。这个规则务必写进组件注释里,后面很多诡异的显示问题就是因为调用方传了一个 empty 类型却配了一套 error 的文案,然后来怪组件有 bug。
2.2 布局结构:不要踩满屏,给边缘留呼吸
空状态组件常见的反人类设计是把内容居中之后左右完全撑满。真机上一跑,鸿蒙设备普遍有比较大的圆角,屏幕左右边缘的物理安全距离和其他平台不一样,内容贴边非常难看。
我的布局结构是 Center 套一个 ConstrainedBox,最大宽度限制在 320 逻辑像素左右,内部用 Column 排列图标、标题、描述、按钮。图标建议用 96x96 的逻辑尺寸,标题字号 16sp 以上,描述字号 14sp,颜色用次级文本色。
dart复制@override
Widget build(BuildContext context) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildIcon(context),
const SizedBox(height: 16),
_buildTitle(context),
if (config.description != null) ...[
const SizedBox(height: 8),
_buildDescription(context),
],
if (config.action != null) ...[
const SizedBox(height: 24),
config.action!,
],
],
),
),
);
}
这段代码没有技巧含量,但它是后面所有适配工作的地基。鸿蒙上出现的中文排版异常、安全区遮挡、按钮点击区域过小等问题,大部分都要回到这个结构里调整。
2.3 为什么用组合而不是继承
我看到过一些团队的封装方式:ErrorEmptyWidget 继承 BaseEmptyWidget,然后重写 build。这是典型的面向对象惯性思维在 Flutter 里的误用。继承意味着父类的任何改动都可能影响子类,而且状态多了之后类层级会越来越深。
空状态组件这个场景,变化维度就三个:类型、文案、操作按钮。全部可以用枚举加数据类表达,组合模式足够了。StatelessWidget 是首选,因为空状态组件本身没有内部状态,所有变化都由外部驱动。如果你发现自己想在空状态组件里维护状态,停下来想想——这个状态是不是应该放在页面层?
3. 鸿蒙真机适配踩坑记录:从日志定位到逐项修复的全过程
3.1 第一个坑:中文排版异常,字变大了也换行了
问题复现:鸿蒙真机(HarmonyOS NEXT 开发者预览版)上,空状态组件的标题文字明显偏大,而且描述文字在 14sp 下居然出现异常换行,iOS 和 Android 上相同代码表现正常。
排查过程:首先排除代码问题,因为同样代码在 Android 模拟器上完全正常。接着怀疑是字体 fallback——鸿蒙的默认字体是 HarmonyOS Sans,它在注册到 Flutter 引擎时,字体度量(font metrics)和思源黑体、Roboto 不一样,同样的 sp 字号下实际渲染的字宽更大。换行问题本质是字体的 letterSpacing 和 wordSpacing 默认值在鸿蒙上被放大了。
修复方案分两步:
第一步,给组件内的文本显式指定字族和样式:
dart复制Text(
config.title,
style: TextStyle(
fontSize: 16,
height: 1.4,
fontFamily: 'HarmonyOS Sans',
// 显式指定,避免引擎 fallback 到不同的字体
),
)
第二步,给描述文本加上最大行数限制和溢出处理,而不是依赖自然换行:
dart复制Text(
config.description ?? '',
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14, height: 1.5),
)
实测下来,这个组合拳解决了 90% 的中文排版异常。剩下的 10% 出现在极端窄屏设备上,ConstrainedBox 的 maxWidth: 320 会被压缩,文本需要在更窄的范围内换行,此时还是有可能出现奇怪的断行位置。最终我把 maxWidth 改成了 MediaQuery.sizeOf(context).width * 0.8 配合 maxWidth: 320 取最小值,问题才彻底消失。
3.2 第二个坑:手势条遮挡和安全区处理
鸿蒙的全面屏手势条是悬浮在页面底部的,和 iOS 的 Home Indicator 类似,但行为细节不同:iOS 的 bottomSafeArea 值包含手势条区域,鸿蒙上 Flutter 的 MediaQuery.padding.bottom 在某些场景下不包含手势条区域,导致内容被遮挡。
复现方法:把空状态组件的操作按钮放在页面底部附近(比如空状态组件下方还有其他内容时),底部按钮会被手势条盖住一部分点击区域。
排查思路:先打印 MediaQuery.of(context).padding 和 View.of(context).viewPadding,对比发现鸿蒙上两个值不一样。Flutter 引擎在鸿蒙的适配层对 viewPadding 的处理和 Android 有差异,MediaQuery.padding 在 Flutter 3.x 里已经合并了 viewPadding 的逻辑,但在鸿蒙分支上有兼容问题。
修复方案:不依赖系统值,自己做一个安全区工具类:
dart复制class SafeAreaUtil {
static double bottomInset(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
final view = View.of(context);
// 鸿蒙分支:取两者的较大值,保证手势条区域不被遮挡
final paddingBottom = mediaQuery.padding.bottom;
final viewPaddingBottom = view.viewPadding.bottom;
return math.max(paddingBottom, viewPaddingBottom);
}
}
然后在空状态组件里:
dart复制Padding(
padding: EdgeInsets.only(bottom: SafeAreaUtil.bottomInset(context) + 24),
child: action,
)
这个修复看起来简单,但定位过程花了不少时间,因为一开始完全没想到是 Flutter 引擎在鸿蒙平台上的 viewPadding 行为差异——它是 Flutter 引擎层的行为,不是 Widget 层能解决的,不了解这一点就会一直在布局层面打转。
3.3 第三个坑:页面切换回来后空状态消失
这是个隐蔽的 bug,只在鸿蒙上出现,而且不是必现的。现象是:A 页面列表为空,显示空状态组件,切到 B 页面再返回,空状态组件不见了,页面变成全白。
初步怀疑是路由问题。排查后发现 Flutter SDK 的鸿蒙分支在页面返回动画期间,对 Visibility 和 Offstage 的处理有 bug,空状态组件内部如果用了 Offstage(比如加载中动画的显隐切换),返回动画完成后 Offstage 的状态没有正确恢复。
我的空状态组件在 loading 类型下用一个自绘的动画组件,动画结束后会把 Offstage 置为 true 隐藏动画。这个 Offstage 状态在页面返回时被误判为已完成挂载,导致渲染树里该组件没有绘制。
修复方案:不用 Offstage 做动画隐藏,改用 Visibility 配合 maintainState: true:
dart复制Visibility(
visible: _isAnimating,
maintainState: true,
maintainAnimation: true,
maintainSize: false,
child: _LoadingIndicator(),
)
Visibility 在鸿蒙分支的实现比 Offstage 稳定,这个坑在 iOS 和 Android 上根本不会暴露,属于平台的偶发性适配问题。如果你在鸿蒙上遇到组件"神秘消失",优先检查是不是用了 Offstage 或 TickerMode 这类控制渲染树挂载的组件。
4. 配合 Provider 做跨页面联动:让组件的显示状态不再各自为战
4.1 为什么选 Provider
项目里状态管理方案用的是 Provider,团队也问过要不要上 Riverpod 或 Bloc,最终没换的原因有两个:一是 Provider 的 ChangeNotifier 模型对中小型项目足够,团队学习曲线平滑;二是空状态组件的状态联动本质上是"单数据源 + 多页面监听",Provider 的 Selector 可以精确到字段级监听,避免多余重建。
如果你关注的是 Flutter 社区最近对状态管理的讨论,可以留意一下 Riverpod 的进展,但我的建议是:项目已用 Provider 就别为了"潮流"换来换去,状态管理方案的一致性比先进性重要得多。
4.2 页面数据状态机设计
空状态不是孤立 UI,它背后是页面数据的状态。我把页面数据状态建模成一个枚举加数据:
dart复制enum PageLoadState {
idle, // 初始
loading, // 加载中
success, // 成功(有数据)
empty, // 成功但无数据
error, // 失败
}
class PageDataModel extends ChangeNotifier {
PageLoadState _state = PageLoadState.idle;
Object? _error;
List<dynamic> _data = [];
PageLoadState get state => _state;
void setLoading() {
_state = PageLoadState.loading;
notifyListeners();
}
void setSuccess(List<dynamic> data) {
_data = data;
_state = data.isEmpty ? PageLoadState.empty : PageLoadState.success;
notifyListeners();
}
void setError(Object error) {
_error = error;
_state = PageLoadState.error;
notifyListeners();
}
bool get isLoading => _state == PageLoadState.loading;
bool get isEmpty => _state == PageLoadState.empty;
bool get isError => _state == PageLoadState.error;
}
页面层用 Consumer 监听,数据是空还是错,自动决定显示列表还是空状态组件:
dart复制Consumer<PageDataModel>(
builder: (context, model, child) {
if (model.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (model.isEmpty) {
return EmptyStateWidget(
type: EmptyStateType.empty,
config: EmptyStateConfig(
title: '暂无数据',
description: '试试下拉刷新',
iconKey: 'ic_empty_box',
),
);
}
if (model.isError) {
return EmptyStateWidget(
type: EmptyStateType.error,
config: EmptyStateConfig(
title: '加载失败',
description: '网络异常,请检查后重试',
iconKey: 'ic_network_error',
action: TextButton(
onPressed: () => context.read<PageDataModel>().setLoading(),
child: const Text('重试'),
),
),
);
}
return child ?? const SizedBox.shrink();
},
)
这套写法的好处是:空状态的显示逻辑收敛在页面层,组件本身不感知业务状态;组件的 onRetry 只负责通知页面层重新加载,不直接处理网络请求,保持了职责单一。
4.3 组件通信:从父到子、从子到父、跨组件三种场景怎么处理
"flutter 组件通信"是常被搜索的话题,这里把和空状态相关的三种场景一并说清楚。
场景一:父组件 → 子组件。最标准的方式就是构造参数传递。空状态组件的 config 和 type 本质就是父组件向子组件的单向数据流,不要用 GlobalKey 去调子组件的内部方法,除非你有明确需求。
场景二:子组件 → 父组件。空状态组件需要通知页面层"用户点了重试按钮",通过回调函数上抛是最直接的:
dart复制// 子组件内
TextButton(
onPressed: () => config.onRetry?.call(),
child: const Text('重试'),
)
// 父组件内
EmptyStateWidget(
...
config: EmptyStateConfig(
onRetry: () => _loadData(),
),
)
场景三:跨组件/跨页面。比如首页有数据了,但个人中心的空状态需要同步刷新。这时候不要用空状态组件的引用直接调,而是通过 Provider 的数据模型来驱动:
dart复制// 某个页面更新了数据,通知到另一个页面
context.read<PageDataModel>().setSuccess(newData);
// 另一个页面通过 Consumer 自动重建空状态
设计原则是:能走数据驱动的别走组件方法调用,能走组件参数的别走全局状态。
4.4 空状态组件的重建粒度控制
用 Provider 的 Consumer 有一个隐患:ChangeNotifier 每次 notifyListeners() 都会重建 Consumer 的 builder 里的所有内容。如果页面同时有列表和空状态,列表数据更新时空状态也会重建,虽然性能开销不大,但在鸿蒙的低端设备上会有掉帧风险。
优化方式是用 Selector 替换 Consumer,只监听 state 字段,不监听整个 model:
dart复制Selector<PageDataModel, PageLoadState>(
selector: (context, model) => model.state,
builder: (context, state, child) {
// state 变化时才重建
...
},
)
这样列表数据变化但 state 没变时,空状态组件不会重建。如果你项目中空状态组件是全局统一使用的,建议在组件内部也加 RepaintBoundary 隔离绘制边界,防止父级重建牵连渲染。
5. 性能调优实测:围绕渲染、重建与图片加载的三轮优化
5.1 优化前先量化:用数据说话
空状态组件虽然简单,但它是高频组件(几乎每个列表页都有),积少成多的性能问题还是值得管一管。我先在鸿蒙真机上用 DevTools 的 Performance 面板量化了三个指标:首帧渲染耗时、空状态显示时的 CPU 占用、从空状态切换到列表时的掉帧数。
基线数据(未优化前,HarmonyOS NEXT 开发版 + Flutter 3.16 分支):
| 指标 | 优化前 | 目标 |
|---|---|---|
| 空状态组件首帧渲染 | 平均 4.2ms | < 2ms |
| 空状态场景 CPU 占用 | 平均占用中等偏上(同类页面中偏高) | 稳定在低位 |
| 空状态→列表切换掉帧 | 偶现掉帧 | 稳定 60fps |
说句实话,4.2ms 的渲染耗时对于单个组件并不算慢,但"空状态显示时 CPU 占用偏高"这个指标指向的不是组件本身,而是组件内部一些隐蔽的重复渲染和资源加载问题。
5.2 第一轮优化:const 声明与组件拆分
空状态组件内部,图标、标题、描述是三个相对独立的区块。如果不做拆分,每次父级 rebuild,整个 Column 都会走一遍 build。用 const 构造和子组件拆分后,没有依赖外部状态变动的部分可以完全跳过 rebuild:
dart复制class EmptyStateWidget extends StatelessWidget {
...
@override
Widget build(BuildContext context) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_EmptyIcon(iconKey: config.iconKey),
const SizedBox(height: 16),
_EmptyTitle(text: config.title),
if (config.description != null) ...[
const SizedBox(height: 8),
_EmptyDescription(text: config.description!),
],
if (config.action != null) ...[
const SizedBox(height: 24),
config.action!,
],
],
),
),
);
}
}
_EmptyIcon、_EmptyTitle、_EmptyDescription 都是独立 StatelessWidget,构造参数里能 const 的都 const。这样父级无论怎么 rebuild,只要传入参数不变,这些子组件直接复用 Element。
实际效果:首帧渲染耗时从 4.2ms 降到 2.8ms,CPU 占用开始下降。但还不够,因为瓶颈不只在 widget build,还在图片资源解码那一层。
5.3 第二轮优化:图片资源的三条硬规则
空状态的图标是性能大头。我用的是 PNG 资源,一开始直接在 Image.asset 里传资源路径,这是错的。三条硬规则:
规则一:图片尺寸必须与显示尺寸匹配,不做运行时缩放。空状态图标显示是 96x96,资源就必须是 96x96(2x 是 192x192)。很多设计师给的切图是 512x512 的,直接塞进去,每次显示都要解码大图再缩到 96,纯浪费。
规则二:用 Image 的 cacheWidth/cacheHeight 参数,在解码阶段就做缩小:
dart复制Image.asset(
iconKey,
width: 96,
height: 96,
cacheWidth: 192, // 2x 设备的逻辑 96 对应物理 192
filterQuality: FilterQuality.medium,
)
规则三:图片资源服务化,不散落管理。我把空状态图标统一放在资源目录下,由组件的 iconKey 映射:
dart复制class EmptyStateIcons {
static const String emptyBox = 'assets/empty/ic_empty_box.png';
static const String networkError = 'assets/empty/ic_network_error.png';
// 这个映射必须和设计规范同步,不能一边改资源名一边改组件代码
}
优化后首帧渲染降到 1.8ms 左右,CPU 占用进一步下降。
注意:使用
cacheWidth后,同一张图片如果被多个组件使用,Flutter 的图片缓存会按不同 cacheWidth 生成多份缓存,所以不要对同一资源在不同地方传不同的 cacheWidth,这会缓存爆炸。
5.4 第三轮优化:RepaintBoundary 与 shouldRepaint 的里应外合
空状态组件在页面切换时会触发重绘。我给组件外层包了 RepaintBoundary,让它在自己的绘制层上独立缓存,不会把重绘范围扩散到整个页面:
dart复制@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: Center(
...
),
);
}
同时,如果你自己写了 CustomPainter(加载动画、骨架屏之类的),一定要正确实现 shouldRepaint。这个方法的返回值决定了 Flutter 要不要重新执行 paint 方法,错误的返回 true 会让 CPU 白跑。
dart复制class _LoadingPainter extends CustomPainter {
final double progress;
final Color color;
_LoadingPainter(this.progress, this.color);
@override
void paint(Canvas canvas, Size size) {
// 画弧线 / 圆环之类的动画
}
@override
bool shouldRepaint(_LoadingPainter oldDelegate) {
return oldDelegate.progress != progress || oldDelegate.color != color;
}
}
这里有个细节:progress 是 double,浮点比较可能因为精度问题永远不等,导致动画每一帧都重绘。优化方式是设一个阈值,比如 (oldDelegate.progress - progress).abs() > 0.001 才返回 true。这个经验在鸿蒙上特别适用——它的 CPU 调度相对保守,不必要的重绘更容易产生可感知的掉帧。
5.5 Flutter Impeller 引擎带来的额外思考
搜热词里大量出现 "flutter impeller",因为 Flutter 3.10 后 Impeller 在 iOS 上默认开启,HarmonyOS NEXT 的 Flutter 分支也逐步接入了 Impeller。Impeller 在渲染管线上比 Skia 更现代化——它预编译所有 shader,运行时不会因为"首次遇到新 shader"而卡顿。
对空状态组件的影响主要体现在动画场景:loading 动画如果使用 Canvas 自绘,Impeller 下 shader 编译开销基本为零,启动和切换动画会更顺滑。但反过来,Impeller 的自定义着色器支持在某些边缘设备上还不够完善,如果你的空状态用了复杂的 ColorFilter 或 BlendMode,建议在鸿蒙真机上多测几个版本。
我目前的做法是:loading 动画优先用 Flutter 内置的 CircularProgressIndicator 或自绘 Canvas,避免使用 ImageFilter 模糊这类重量级效果,这样在 Skia 和 Impeller 两个渲染引擎下都有稳定的性能表现。
6. 沉淀组件库的最后一公里:接口设计、兜底方案与排期策略
6.1 别让空状态组件默默失败
一个容易被忽视的问题:空状态组件本身也可能渲染失败。比如 iconKey 传了一个不存在的资源,Image.asset 会抛异常,整个页面白屏。用户看到的是"页面崩了",定位问题费半天劲。
解决思路是给空状态组件加一个静默兜底:包一层 ErrorWidget.builder 拦截,组件渲染失败时显示一个极简的灰色占位符,同时把错误信息上报到日志平台:
dart复制class EmptyStateWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ErrorWidget.builder != null ? _buildWithFallback(context) : _buildNormal(context);
}
Widget _buildWithFallback(BuildContext context) {
return Builder(
builder: (context) {
// 用一个局部 ErrorWidget.builder 覆盖,只拦截本组件的异常
final originalBuilder = ErrorWidget.builder;
ErrorWidget.builder = (FlutterErrorDetails details) {
// 上报日志(对接你的埋点系统)
return const ColoredBox(
color: Color(0xFFF5F5F5),
child: Center(child: Text('内容加载中')),
);
};
final widget = _buildNormal(context);
ErrorWidget.builder = originalBuilder;
return widget;
},
);
}
}
注意:全局替换 ErrorWidget.builder 风险很大,一定要做局部恢复(上面的 originalBuilder 还原逻辑)。我的线上版本里这个兜底救过好几次急——有一次设计师更新了资源名但没通知开发,iconKey 全挂了,兜底生效后页面至少没有白屏,错误日志也精准定位到了问题。
6.2 接口语义化:让调用方一眼就能用对
组件库的接口设计决定了业务方的开发体验和出 bug 的概率。我给空状态组件的接口定了几条"防呆"规则:
-
禁止魔法数字。不要在业务方写
EmptyStateWidget(title: '暂无数据', fontSize: 13),字号、间距是组件内部的视觉规范,外部只需传语义字段。 -
明确状态转换关系。
type和config的矛盾处理在注释里写清楚,文档和代码保持一致。 -
默认值合理化。
title默认 '暂无数据',iconKey默认ic_empty_box,业务方不传也能看到可接受的 UI,这个设计让组件被使用的心理门槛大大降低。 -
留好扩展位。未来可能新增"无搜索结果""未登录"等状态,所以
type用枚举而不是字符串,新增枚举值不会影响已有调用,字符串匹配反而会埋雷。
6.3 排期建议:空状态组件和业务页面谁先做
从项目管理的角度提一个个人建议:空状态组件应该在业务列表页开发之前完成,而不是等列表页需要时再写。因为空状态是跨页面通用组件,它的视觉规范需要提前冻结,否则等 5 个页面的空状态都写完了再统一,改版成本直线上升。
我在这个项目里是先做了组件库(包括空状态、Toast、弹窗、下拉刷新),再排业务页面。虽然前期看着"没产出具体业务功能",但后期业务开发的顺畅程度完全值得这个前置投入。尤其是鸿蒙这种新平台,组件层多踩一个坑,业务层就少踩一个坑。
6.4 关于鸿蒙适配的一个心态建议
最后说点掏心窝的话。鸿蒙的 Flutter 适配还在快速演进期,网上搜到的很多结论到下一个版本可能就变了。我在适配过程中遇到过 SDK 分支里 bug 导致的诡异渲染、遇到过 MediaQuery.padding 返回值不统一、遇到过 Offstage 状态丢失。这些问题的共同特征是:先在 iOS/Android 上验证逻辑对错,再针对鸿蒙做平台适配。如果你一上来就在鸿蒙上排查代码逻辑,很可能永远查不出来,因为问题根本不在逻辑。
现在做鸿蒙 Flutter 开发,有点像是在新修的高速公路上开车,路况是好的,但偶尔会有没画完的标线。把心态放平,遇到问题多留日志、多对比 iOS/Android 的表现差异,很多坑是可以凭经验绕过去的。我这套空状态组件在设计思路上和 iOS/Android 完全通用,鸿蒙上多做的只是安全区、字体渲染和引擎差异的适配,有了这次积累,下一个组件再适配鸿蒙时就有谱了。
