1. 类与继承的进阶:不只是“万物皆对象”
上一期我们聊完了Dart的基础语法和异步入门,这期直接进入中高阶玩法。说实话,很多人学Dart有个误区——以为能写几个类、会用async/await就算会了。但真要拿 Flutter 去适配 OpenHarmonyOS,光会这些是远远不够的。你面对的是一套不同于 Android 的底层机制,窗口管理、事件分发、生命周期全都要重新对接,这时候如果你对 Dart 的类体系理解不透,写插件的时候会非常痛苦。
1.1 mixin:Dart 和 Java 最大的不同之一
先说说 mixin。这是 Dart 里我最喜欢的特性之一,也是 Flutter 框架里无处不在的东西。你打开任何一个 Flutter 页面,几乎都会看到with SingleTickerProviderStateMixin这样的写法——加了它你的State才能拿到vsync,才能在动画驱动的时候避免资源浪费。在 OHOS 适配的场景下,你同样需要用 mixin 去组合来自不同平台抽象层的逻辑。
mixin 的核心价值在于“横切复用”。一个类只能继承一个父类,但可以用with混入多个 mixin,解决的是“一堆类都需要同一个能力,但又不适合放进公共基类”的问题。我举个实际例子,你要给不同的数据源写适配层,有从网络来的、有从本地数据库来的、还有从云端同步来的。这三类的公共逻辑是“都要处理生命周期回调”,放在BaseAdapter里吧,将来某个类没法继承它怎么办?用 mixin 就干净多了:
mixin LifecycleAware { void onStart() { // 默认空实现 } void onStop() { // 默认空实现 } } class NetworkAdapter extends BaseAdapter with LifecycleAware { @override void onStop() { // 在这里释放网络连接 } }注意一个容易踩坑的点:mixin 可以用on关键字限制混入类型。比如mixin LifecycleAware on BaseAdapter,这意味着只有BaseAdapter的子类才能混入这个 mixin。这在大型工程里很有用,能避免你拿它去干不该干的事。
1.2 工厂构造函数:接口到对象的最后一公里
Dart 的构造函数有几种,默认的、命名的、重定向的,但最容易被忽略的是factory。工厂构造函数不要求每次调用都创建一个新对象,它完全由你说了算。
在鸿蒙适配的插件层里,最常见的一个需求是:根据底层返回的平台类型,创建对应的 Dart 对象。比如你在 OHOS 上拿到的platformObject可能是TextInputHandler,可能是PlatformChannel接口的具体实现,Dart 侧不能直接new这些类——它们的实际类型隐藏在原生层。工厂构造函数是绝佳的桥接点:
class PlatformTextInput { final int id; PlatformTextInput._(this.id); factory PlatformTextInput.create(Map<String, dynamic> params) { // 根据参数返回不同的缓存实例或新实例 int id = params['id'] as int; if (_cache.containsKey(id)) { return _cache[id]!; } final instance = PlatformTextInput._(id); _cache[id] = instance; return instance; } }这里有几层用意。第一,工厂函数里可以做缓存,避免同一原生对象被 Dart 侧重复包装成多个实例,这在底层资源有限时特别关键;第二,工厂函数可以在返回前执行初始化逻辑,比如往平台通道里注册监听,而不是把初始化工作交给调用方,减少漏写;第三,工厂函数可以返回子类的实例,这是普通构造函数做不到的。
1.3 算子重载与值对象:你写的“==”可能一直不对
Dart 的每个类默认继承自Object,默认的==比较的是内存地址。很多新手在写状态管理的时候,直接拿user == otherUser去做判断,结果永远为false。在 Flutter 里,这能直接导致界面该刷新的地方不刷新,或者反过来,不该重建的在疯狂重建。
算子重载的正确用法是把类设计成“值对象”。比如在鸿蒙适配时需要维护一个窗口尺寸类:
class WindowSize { final double width; final double height; const WindowSize(this.width, this.height); @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is WindowSize && other.width == width && other.height == height; } @override int get hashCode => Object.hash(width, height); }配合const构造函数,值对象在编译期就能被复用,加上正确的==,你可以在Widget的shouldRepaint里直接判断尺寸是否变化,性能提升非常明显。说到hashCode,规则很简单:重写==就必须重写hashCode,否则放进Set或Map里就是灾难。Object.hash这个顶层函数帮我省了不少事,你直接拿它组合所有字段就行。
1.4 接口与隐式接口:Dart 的“interface”就是一个类
很多之前写 Java 的人刚转到 Dart 都很懵:Dart 没有interface关键字?对,因为每个类都隐式定义了一个接口。你不需要单独声明XxxInterface,直接拿已有的类去implements就行。
这在插件开发里特别实用。假设你在platform_text_input.dart里导出了NativeTextInputService这个类,它只是想复用“接口形状”,不想继承它的实现——直接用implements:
class MockTextInputService implements NativeTextInputService { @override void showKeyboard() { // 测试环境下的模拟实现 } }注意,implements会要求你重写所有成员的实现,因为这个接口不继承任何具体代码。在适配层写这种 Mock 类的时候很有用,你可以让上层代码在测试模式下不经过真实的原生通道,完全走 Dart 侧逻辑,验证状态机的正确性。
2. 异步编程的进阶套路:隔离、事件循环与Stream深度
2.1 事件循环模型:单线程是怎么做到不卡UI的
Dart 是单线程的,但它是“事件驱动、异步不阻塞”的单线程。整个模型可以理解成一台“流水线机器”,主线程一直在跑一个事件循环,不断从事件队列里取任务执行。微任务队列的优先级高于事件队列,所以Future.microtask会在下一个事件到来之前执行。
这个模型意味着你在 Dart 里写“阻塞型”代码后果很严重。我的建议是:凡是超过几十毫秒的操作,一定要丢到 isolate 或至少用 Future 包装。特别要注意的是,很多人以为把for循环里的耗时计算变成async函数就万事大吉,实际上如果计算本身在异步函数里还是同步执行的,依然会卡线程。这在鸿蒙适配的场景里更为关键——如果 UI 线程被你的 Dart 计算阻塞,你在应用层根本看不到异常,只会发现页面莫名其妙的掉帧,触摸事件滞后。
2.2 Future的冷知识:链式调用与错误吞噬问题
Future本身不难,难的是用对。两条要点必须说清楚:
第一,Future 的执行时机不取决于你声明它的时机,而取决于事件循环的调度顺序。所以:
Future<void> main() async { print('开始'); Future(() => print('事件1')); await Future.microtask(() => print('微任务')); print('结束'); }输出顺序是“开始 -> 微任务 -> 结束 -> 事件1”,因为微任务队列总是先于事件队列。这个特性在 UI 框架里非常关键,比如你希望在完成布局计算之后再处理平台消息,就得用Future.microtask或scheduleMicrotask来控制,而不是直接丢一个Future完事。
第二,异步链中的错误处理要特别注意。Future链上一个不显眼的异常可能会被静默吞掉。很多人习惯只写try/catch但忘了用catchError去捕获链上的Future延迟异常。还有一个冷门细节:await Future.wait([...])如果其中一个失败,默认会快速失败,除非你传入eagerError: false。在并发加载多个平台资源时期,这个参数直接决定你的应用是“闪退”还是“给降级机会”。
2.3 Stream:单订阅流与广播流之间没有“中间态”
Stream是 Flutter 的灵魂之一,也是 EventChannel 的底层抽象。在 OHOS 适配层,你要和原生的各种传感器事件、系统广播、生命周期回调打交道,几乎全部通过Stream传达给 Dart 层。
单订阅流(Single-subscription)只能被监听一次,就像一条录音一样,播完就没了——常用于文件读取、网络响应这类一对一的场景。广播流(Broadcast)则允许多个监听者,就像电台信号,你可以有很多收音机同时在听——EventChannel.receiveBroadcastStream()就是一个典型例子,多个 Widget 可以同时监听同一个事件流。
一个容易踩的坑:单订阅流如果没人监听、数据不会消失,它会缓冲。这在某些场景下非常危险,比如你连续往流里塞了三笔事件,UI 层才开始监听,结果一下全涌出来,界面瞬间异常。
常见的一个坑是把Stream当作可以直接重新“cool”的东西,ES6 的迭代器才支持重启。Dart 的 Stream 不是,你需要重新创建它。所以在处理 EventChannel 时建议封装一层,在需要重连时直接调用EventChannel.receiveBroadcastStream()拿一个新流,而不是试图复用旧的。
2.4 async* 与 yield:生成器其实不难
2.4 async* 与 yield:生成器其实不难
如果你需要在 Dart 里懒加载一序列数据,用async*配合yield是一种极优雅的写法。async*标记的函数会返回一个Stream,每次yield就往外吐一个值。我举个更贴近 Flash/OHOS 适配场景的例子:你要持续监听系统屏幕亮度变化,定期把新值推送出来。
Stream<double> watchSystemBrightness() async* { int count = 0; while (count < 100) { // 模拟每100毫秒主动去原生层查询一次 await Future<void>.delayed(const Duration(milliseconds: 100)); double current = await platform.invokeMethod<double>('getBrightness'); yield current; count++; } }注意,你也不要在async*里搞阻塞操作,生成器里的等待也会占用事件循环资源,只是不像同步那样卡死了而已。
await for是另外一个好东西,它可以直接在异步函数里循环读取 Stream 里的每个值,不用写一堆listen().onData回调:
Future<void> collectStreamEvents(Stream<UiEvent> events) async { await for (final event in events) { handleUiEvent(event); // 处理每一个事件 } }问得最多的问题是“为什么我的await for一直都在等待、后面的代码不执行”?因为你的 Stream 没有close。单订阅流在发送完最后一个值后,一定要记得调用close()(或让async*函数正常返回),await for才会跳出。
3. 库与模块化:part、export 与可见性,工程结构才是体面
3.1 用 part 还是 export?看的是边界
part在 Dart 里有点“强行把一个文件的内容并入另一个文件”的意思,也就是库文件可以将实现拆到多个文件,但全部共享同一个库作用域。
真正适合part的场景是:当你有一个好几百行的类,想把它按功能拆到不同文件,但又希望它们能直接互相访问“私有”成员的时候。比如在大型的状态管理模型里,把同一个 State 的_onLoading、_onCompleted拆开写,放在不同文件里用part组装,读起来确实清爽。
但大多数时候你要用export而不是part。dart官方也推荐用part时保持谨慎,因为它破坏了文件边界的清晰性——一个被part的文件很难被独立理解。
更实用的模式是集中导出。比如你在adapters/目录下放了五个适配器文件,上层只需要 import 一个入口就行:
// adapters/adapters.dart library adapters; export 'network_adapter.dart'; export 'file_adapter.dart'; export 'platform_adapter.dart';调用方只要写import 'adapters/adapters.dart';,就能用到所有适配器。配合show和hide还可以精确控制命名空间,避免多个库之间的同名类冲突。
3.2 可见性:Dart没有private关键字,但到处都是private
Dart 的私有是通过命名实现的:以_开头就是库私有。注意,库里可见,跨库不可见,不是类级私有。这部分学起来会有点绕——在同一个文件里的两个不同类,互相能访问对方的_xxx成员,但放到不同文件后就完全不行了。
在插件适配的时候这个规则影响很大。比如你要让某个实现类只在库内部使用、不向外暴露,但底层逻辑复杂到需要拆文件——这个时候part就体现优势了,因为你拆出去的文件仍在同一个库作用域里,可以随意访问所有下划线成员。
我建议你在开发时少用“单下划线也不跨文件”这种严格思维,直接把每个文件都当成一个独立的小世界,所有不对外暴露的都标记_,需要跨文件访问的部分剥出来用公共接口或part。
3.3 const 和 final:编译期常量不只是省内存
在适配层写代码时,最容易被忽略的性能优化点之一就是常量。const是编译期常量,意味着它在编译器被展开、被复用,不会在运行时反复创建对象。
Flutter 里广泛推荐const构造函数,Sass 的Transform、Padding、Color这些高频组件,如果能用const构造,你在重建 Widget 树时就能省掉大量对象创建的开销。为什么这跟鸿蒙适配相关?因为 OHOS 上的基础性能指标和 Android 不是在同一个水平线上,对资源占用更敏感,能用const节省的都是实打实的帧率。
final是运行时常量,赋值之后不能改。区分final和const最直观的方法:如果你确定这个值在写代码时就已经知道了——比如版本号、平台标志——用const;final则用于那些运行期才会确定的一次性赋值。
4. 集合、泛型与函数式编程:Dart的表达力比你想的更猛
4.1 集合字面量里的 if 和 for:声明式UI的隐藏弹药
4.1 集合字面量里的 if 和 for:声明式UI的隐藏弹药
Flutter 的 “everything is a widget” 口号大家都很熟了,但我发现很多初学者并没有真正吃透 Dart 声明式语法的精髓——集合字面量里的if和for。 它可以直接在List、Map、Set初始化时做条件判断和循环展开,减少大量模板代码。
List<Widget> buildActionButtons({required bool isEditable, required int count}) { return [ if (isEditable) EditButton(), for (var i = 0; i < count; i++) ItemBadge(index: i), ]; }这种写法本质上是一颗“代码即 UI 结构”的语法糖。你不必再写三行if判断一个元素要不要加入列表,直接在字面量里写条件,代码紧凑且语义清晰。它在鸿蒙适配的 UI 层同样重要——当你根据平台能力动态渲染窗口控件时,一套代码里写两个平台的分支就能直接体现。
配合...展开操作符,还可以把多个列表合并成一个动态视图列表:
List<Widget> headerActions = [ ...commonActions, ...(isOHOS ? ohosSpecificActions : []) ];4.2 泛型约束:让你的优雅尽在编译期
泛型的核心价值不是“类型安全”四个字,而是“把错误控制在编译期”。比如你要写一个通用的按键事件处理器,不同平台有不同的KeyEvent子类,你可以约束泛型必须继承某个基类:
class KeyEventProcessor<T extends KeyEvent> { void dispatch(T event) { // 处理 } }这样传入非KeyEvent的子类,编译就直接报错。在适配层设计通用组件时,这种约束能防住一大堆未来才显现的运行时崩溃。
另外,Dart 的泛型还有一处容易被忽视——泛型的运行时“reify”行为。List<int>和List<String>在运行时是不同的类型,这点和 Java 的擦除机制不一样,所以你可以用if (obj is List<int>)做动态判断。这在解析平台返回的异构数据时非常好用。
4.3 级联操作符与高阶函数:优雅地完成“流水线”处理
..级联操作符允许你在同一个对象上一连串调用它的方法,而无需重新引用该对象。比如:
final channel = EventChannel('flutter/ohos_sensor') ..setMethodCallHandler(_onMethodCall) ..invokeMethod('enable', true);这里返回的始终是channel对象,而不是setMethodCallHandler的返回值。原因非常像 Builder 模式,只是语法层已经内置了。在写配置代码或者给对象一连串赋值时,绝不失优雅。
高阶函数map、where、fold是函数式编程的日常。它们的思想是——你提供一个变换/过滤/累积函数,库帮你遍历集合并产出结果,避免 for 循环的样板代码。我在处理 OHOS 原生返回的原始Map列表时,基本上都靠一套map+where+fold流水线把数据转换成 Dart 模型,简洁且容易测试。
说到...展开操作符,还有一个容易被忽略的“空安全”细节:...?空感知展开符,当列表为 null 时不会崩溃,直接展开成空列表。适配时数据可能来自各种地方,加一个?就是给自己买道保险。
5. 错误处理、调试与FA
5.1 你写的try/catch可能是“假保护”
错误处理是所有语言都要讲的,但 Dart 的一个特性是:未捕获的异步异常不会导致立即崩溃,只会在控制台留一条日志。这听起来很友好,但实际上非常坑——线上的异常就这样无声无息地丢了。我在做适配层时经常干的一件事:用一个全局的Zone钩子把未捕获异常带到本地日志,保证异步异常至少能被我看到。
void main() { runZonedGuarded(() { runApp(const MyApp()); }, (error, stackTrace) { // 上报、落盘、或者弹提示 }); }另一个陷阱是on子句的类型捕获范围。catch (e)是捕获所有异常,而on IOException只捕获指定类型。很多新手分不清,导致捕获顺序错乱,捕获类型过宽的会吞掉不该吞的异常。我习惯上:先捕获具体类型,再捕获通用异常(兜底),最后用rethrow保留原始堆栈,这样日志链路才不会被截断。
5.2 assert、debug模式与“线下验证有效,线上翻车”的破解
assert只在 debug 模式下生效,非 release 模式时完全没开销。这个特性用来做开发期验证刚刚好。但有一类 bug 只会在 release 模式下出现——比如依赖了assert来做流程控制,就会在发版后直接躺尸。
我的做法是:重度依赖“平台无关的单元测试”而不是assert。围绕 Dart 的模型层写纯 Dart 测试,不依赖任何 Flutter UI。这样在适配到 OHOS 时,所有与系统相关的解码、重组、通道逻辑都可以先在本地跑通,真正做到“跨平台逻辑可验证”。这是我在做 Flutter OHOS 适配时最受益匪浅的一个习惯。
5.3 常见问题速查:Dart异步与集合组合拳
为了方便你排查问题,我把这段时间在 Dart 侧遇到的高频问题整理成了表格:
| 问题 | 典型原因 | 排查思路 |
|---|---|---|
| Stream事件迟迟不来 | 单订阅流没有监听者或数据缓冲未消费 | 检查是否尽早调用了listen,不要把单订阅流当广播流 |
| await for一直不往下执行 | Stream一直没有关闭 | 确认在适合的时机调用了close或使用了可关闭的控制器 |
| == 统一比较失效 | 没有重写 hashCode 或值对象使用了引用比较 | 重写==和hashCode,用identical做短路判断 |
| 高 CPU / 卡顿 | Dart层做了大量同步计算 | 通过 isolate 或compute移到后台执行 |
| release 和 debug 行为不一致 | 误用了assert控制流程 | 把关键校验改用单元测试或显式throw |
| 集合展开时崩溃 | null 元素在展开时被解引用 | 使用...?空感知展开符 |
| 插件方法调用异常 | 平台通道尚未就绪或未配置 | 确认主线程调用、通道名一致、原生侧已经注册对应handler |
这张表覆盖面还可以更广,但核心思路是:遇到问题先定位它发生在哪个执行环节(同步/异步/跨平台),再去对照 Flutter 适配层的行为,你能少浪费很多排查时间。
6. 一个实操收尾:把 Dart 侧的事件通道用 Stream 包起来
最后用一个我最近在 OHOS 适配里反复用到的小实操做收尾。业务需求是:底层不断上报“横竖屏切换事件”,Dart 侧希望通过统一的Stream对外暴露,所有页面或组件都能订阅。直接拿EventChannel.receiveBroadcastStream()也行,但每次监听都会重新订阅原生层,容易产生重复事件。
我的做法是做一个单例的“事件桥接”,内部维护一个StreamController.broadcast()。
class OrientationEventBridge { OrientationEventBridge._(); static final OrientationEventBridge instance = OrientationEventBridge._(); final _controller = StreamController<OrientationEvent>.broadcast(); Stream<OrientationEvent> get onOrientationChanged => _controller.stream; void bindNativeCallback() { _eventChannel.receiveBroadcastStream().listen((event) { _controller.add(OrientationEvent.fromMap(event)); }); } }这里StreamController.broadcast()的关键性在于:多个订阅者不会互相干扰,新订阅者不会丢失未来事件(当然,当前时刻之前的事件并不会补发)。你需要把原生层的回调转换为统一的 Dart 事件流,再转发给 UI 层。
在实现时还必须考虑生命周期问题。页面切换时如果不取消订阅,很容易触发事件泄漏。建议在页面的dispose方法里调用StreamSubscription.cancel()。如果你在适配鸿蒙的时候遇到订阅了事件但页面销毁后还在不断回调的情况,优先检查这里,十有八九是cancel没写。
我个人在实操中最重视的一条准则是:边写边确认消费和释放的配对关系。不管你是用part搭建内部工具库,还是用export做外部统一出口,最终都会被底层原生层用“你是否留下了悬空订阅、未释放资源”来验收。Dart 给了你非常顺滑的异步工具,但不代表你能一直旁若无人地创建 Stream。
就到这里,下篇讲完,先去把你项目里的异步回调理一理,再回头看看这几章节里的细节——会有不少新的体会。