1. 项目背景与价值定位:为什么要在鸿蒙里自己写一个 stack
1.1 Flutter 适配鸿蒙的现状与三方库的尴尬
先说结论:在 Flutter for OpenHarmony 生态下写一个 stack 数据结构,不是吃饱了撑的,而是现阶段鸿蒙适配过程中非常现实的需求。
Flutter 官方对 OpenHarmony 的支持目前还在持续完善中,很多在 Android / iOS 上习惯直接pub add的三方库,到了鸿蒙侧不一定能直接跑通。原因很简单——一部分纯 Dart 实现的库可能没问题,但凡是依赖了 Flutter SDK 内部接口、或者依赖了平台通道(platform channel)的库,在鸿蒙的 Flutter 引擎上就需要单独适配。而鸿蒙 NDK / ArkTS 侧的接口跟 Android 的 JNI / iOS 的 Objective-C 桥接完全不是一套东西,三方库作者如果没跟进,那这个库基本就用不了。
这时候自己写一个轻量级的三方库,反而成了最稳妥的方案。stack 这种数据结构非常典型:逻辑简单、边界清晰、依赖极少,非常适合作为“第一个跑通鸿蒙适配流程”的自研组件。同时它也是后续做表达式求值、撤销重做、括号匹配、深度优先搜索这类算法的基础引擎,属于那种“虽然不起眼,但到处都要用”的基础设施。
1.2 LIFO 栈到底解决什么问题
栈(stack)的核心语义是后进先出(LIFO, Last In First Out)。拿现实生活类比,就像摞盘子——你总是先拿最上面那个,最后放上去的盘子会被最先取走。
在软件开发里,这个数据结构解决的核心问题是“状态回溯的最近优先”。举几个非常具体的场景:
- 文本编辑器的撤销(Undo)操作:每次操作压入栈中,撤销时弹出最近一次操作。
- 函数调用栈:系统本身就是用栈管理函数调用的,你写递归的时候其实就在用栈。
- 括号匹配:解析
({[]})这类表达式时,遇到左括号压栈,遇到右括号弹栈并检查是否匹配。 - 浏览器 / App 页面导航:返回键对应的就是出栈操作。
- 计算器表达式的后缀表达式求值:操作数压栈,遇到运算符弹两个数计算再压回。
也就是说,stack 虽然代码量可能只有几十行,但它承载的是一整类算法的底层逻辑。在很多 Flutter 业务场景中(比如表单分步填写、Step 流程控制、页面状态回溯),自己维护一个 LIFO 容器比依赖第三方状态管理库更轻、更可控。
1.3 “轻量级”三个字背后的设计取舍
标题里特意强调“轻量级”,这背后是有设计考量的。
常规的集合库(比如package:collection)里的队列、栈实现,会考虑非常多的边界场景和性能优化,体积相对较大,而且不一定为鸿蒙算力环境做了适配。而当我们说“轻量级”的时候,实际是在做三个取舍:
第一,依赖最小化。整个库只依赖 Dart SDK 自带的dart:core,不引入任何 Flutter 引擎相关的内容。这意味着它在鸿蒙侧编译时,不会触发平台通道适配问题,天然具备跨端兼容性。
第二,实现扁平化。不搞复杂的类继承树,一个Stack类 + 一个内部存储List,所有操作直接在这两层上完成。这样做的好处是调用链路短,性能损耗小。嵌入式设备、IoT 设备上的 OpenHarmony 系统资源通常有限,一个轻量的数据引擎能让 GC(垃圾回收)压力明显下降。
第三,API 克制度。只暴露真正高频使用的方法——push、pop、peek、isEmpty、isNotEmpty、length、clear,外加一个toList。够用,但不臃肿。
2. 核心细节解析:一个 stack 需要覆盖哪些能力
2.1 基础操作:push、pop、peek 的语义与边界
先明确这三个核心操作的语义,因为它直接关系到调用的正确性。
push(value)是把元素压入栈顶。这个操作不涉及边界问题,只要内存够,任何时刻都能执行。
pop()是从栈顶取出元素并将其移除。这里的边界情况是“空栈弹出”——如果栈里没有任何元素,你还去 pop,应该怎么办?业界常见的做法有两种:一种是抛异常(Java 的Stack就是这么干的),另一种是返回null/ 默认值(JavaScript 的Array.pop()就是这么干的)。
考虑到 Flutter / Dart 语境下的容错需求,我选择了返回null。原因在于鸿蒙侧很多场景下 stack 是被当作“可选状态”使用的,弹到空栈往往意味着“没有更多历史记录”,这时候返回一个优雅的空值比抛异常更符合业务逻辑。但要注意,使用方需要自己处理 null 的情况。
peek()是查看栈顶元素但不移除。它同样有“空栈查看”的问题,处理方式与pop保持一致——返回null。
这三个方法实现不复杂,但语义边界一定要在文档里写清楚,否则使用方很容易踩坑。
2.2 栈的容量控制与动态扩容
Dart 的List是动态增长的,所以理论上我们的 stack 不需要手动管理容量。但不能完全不考虑内存问题——在鸿蒙环境(尤其是内存受限的设备)上运行 Flutter 应用,一个失控的 stack(比如死循环一直 push)是有可能把内存吃干净的。
所以有两个控制方向:
- 提供可选的最大容量限制(
maxSize),超过时抛异常或者拒绝入栈。 - 提供
shrink机制,当栈的使用率极低时,主动释放内部数组的容量。
我倾向于在库中支持可选的maxSize参数,默认是-1表示不限制。这样在需要保护内存的场景(比如处理外部输入数据流)可以显式限制栈的深度,避免异常数据导致 OOM。
2.3 迭代与遍历的顺序设计
stack 的遍历有一个经典的语义问题:从栈顶到栈底遍历,还是从栈底到栈顶遍历?
这决定了toList()方法返回的列表顺序。我用了一个比较直观的约定:
toList()返回从栈顶到栈底的列表(即第一个元素是栈顶)。toListFromBottom()返回从栈底到栈顶的列表(即第一个元素是栈底)。
这样做的原因很容易解释:栈的核心操作是从栈顶开始的,遍历时最常用的场景是“把当前栈内容倒出来看看”,这时候栈顶在前的顺序更接近人工读取的习惯。反之,如果是要序列化保存整个栈的原始内容(比如做持久化),从栈底开始会更自然,因为你 push 的顺序就是数据产生的顺序。
同时实现forEach回调接口,默认按栈顶到栈底顺序回调,并且支持在回调中提前终止遍历(返回false即停止)。
2.4 泛型设计与类型安全
Dart 的泛型在运行时是会被擦除的(类似 Java 的 type erasure),但在编译期仍然能提供很好的类型安全保障。我用Stack<T>这样的泛型类声明,能确保:
- 你压入
int的栈,pop出来的始终是int?,编译器会帮你做类型检查。 - 配合 Dart 的
sound null safety,在编译期就能拦截“往Stack<String>里塞int”这类低级错误。
这在鸿蒙生态里尤其重要。Flutter for OpenHarmony 目前的编译链路比标准 Flutter 长,如果能在编译期暴露的类型问题拖到运行时才爆出来,排错成本会翻倍。
3. 实操过程与核心环节实现
3.1 基于 List 的存储设计与初始化
先给出一个完整的类骨架。这个设计主要参考了 Java 的java.util.Stack和 Dart 社区的一些轻量实现,但为了适配鸿蒙环境做了几处定制。
/// 一个轻量级的 LIFO 栈实现,专为 Flutter for OpenHarmony 环境适配。 /// 仅依赖 dart:core,不涉及任何平台通道,可直接在 ohos 设备上运行。 class Stack<T> { // 内部存储:使用 List 作为底层容器 final List<T> _elements; // 可选的最大容量限制,-1 表示不限制 final int maxSize; /// 构造函数,可指定初始容量和最大容量。 /// /// 注意:maxSize 设置为 -1 表示不限制栈的深度, /// 在内存受限的鸿蒙设备上建议设置合理上限。 Stack({int initialCapacity = 16, this.maxSize = -1}) : _elements = List<T>.empty(growable: true) { if (initialCapacity < 0) { throw ArgumentError('initialCapacity 不能为负数'); } if (maxSize != -1 && maxSize < 1) { throw ArgumentError('maxSize 必须为正数或 -1'); } } /// 返回栈中元素个数 int get length => _elements.length; /// 栈是否为空 bool get isEmpty => _elements.isEmpty; /// 栈是否非空 bool get isNotEmpty => _elements.isNotEmpty; }这里有一个细节值得展开:List<T>.empty(growable: true)是 Dart 2.x 之后推荐的可扩容列表创建方式。在 Flutter 的旧版本里大家习惯用List<T>()直接创建,类型推断在某些边界条件下会出问题,empty(growable: true)写法更明确,编译期也更安全。
3.2 核心方法实现:入栈、出栈、查看栈顶
接下来是三个核心方法。这里最关键的就是空栈处理逻辑,注释是我实际跑鸿蒙设备时踩过坑后补的。
/// 将元素压入栈顶。 /// /// 如果设置了 maxSize 且当前栈已满,抛 StateError 异常。 void push(T element) { if (maxSize != -1 && _elements.length >= maxSize) { throw StateError('栈已满,容量上限为 $maxSize'); } _elements.add(element); } /// 弹出栈顶元素。 /// /// 如果栈为空,返回 null;否则返回栈顶元素并移除。 /// /// 注意:返回类型为 T?,调用方需要处理空栈的情况。 /// 这与 Java Stack 的 pop() 行为不同,Java 版本会抛异常。 T? pop() { if (_elements.isEmpty) { return null; } return _elements.removeLast(); } /// 查看栈顶元素但不移除。 /// /// 如果栈为空,返回 null。 T? peek() { if (_elements.isEmpty) { return null; } return _elements.last; }有朋友可能会问:removeLast()和removeAt(length - 1)有什么区别?实际上对于List来说,removeLast()是一个专门优化的方法,语义上等价于“弹出最后一个元素”,复杂度为 O(1)。而removeAt(index)底层可能涉及元素移位,虽然在这个位置上也不会有移动,但代码可读性上removeLast()更贴切栈的语义。能在底层不做多余事情,就坚决不做。
3.3 辅助方法:清空、查找、遍历、转列表
除了三个核心操作,一个完整的基础算法引擎还需要几个辅助方法。它们不直接影响 LIFO 的核心语义,但在实际业务组合中非常常用。
/// 清空栈中所有元素。 void clear() { _elements.clear(); } /// 判断栈中是否包含某个元素。 /// /// 这里用 == 判断,如果有自定义对象请自行重写 == 和 hashCode。 bool contains(T element) { return _elements.contains(element); } /// 按从栈顶到栈底的顺序遍历。 /// /// 如果回调函数返回 false,则提前终止遍历。 void forEach(bool Function(T element) callback) { for (var i = _elements.length - 1; i >= 0; i--) { if (!callback(_elements[i])) { break; } } } /// 将栈转为 List,顺序为从栈顶到栈底。 List<T> toList() { return _elements.reversed.toList(); } /// 将栈转为 List,顺序为从栈底到栈顶(与 push 顺序一致)。 List<T> toListFromBottom() { return List<T>.from(_elements); } }这里toList()使用了reversed属性。需要注意_elements.reversed返回的是一个Iterable<T>,必须调用.toList()才会生成新的列表。如果不调用toList(),直接赋值给List<T>,类型上会报错。
3.4 单元测试:验证 LIFO 语义与边界
测试是必须的。在鸿蒙侧跑 Flutter 测试有一个特点:如果你写的库没有任何平台相关代码,那测试可以跑在标准 Dart VM 上,完全不需要连接鸿蒙设备。这也是我们强调“只依赖dart:core”的直接好处——开发效率会高出很多。
测试用例的基本盘如下:
import 'package:flutter_test/flutter_test.dart'; import 'package:stack_lifo/stack_lifo.dart'; void main() { group('Stack 基础 LIFO 语义', () { test('push 和 pop 遵循后进先出', () { final stack = Stack<int>(); stack.push(1); stack.push(2); stack.push(3); expect(stack.pop(), 3); expect(stack.pop(), 2); expect(stack.pop(), 1); expect(stack.isEmpty, isTrue); }); test('peek 不改变栈状态', () { final stack = Stack<String>(); stack.push('a'); stack.push('b'); expect(stack.peek(), 'b'); expect(stack.length, 2); expect(stack.pop(), 'b'); expect(stack.pop(), 'a'); }); test('空栈 pop 返回 null 而不抛异常', () { final stack = Stack<int>(); expect(stack.pop(), isNull); expect(stack.peek(), isNull); }); test('maxSize 限制生效', () { final stack = Stack<int>(maxSize: 2); stack.push(1); stack.push(2); expect(() => stack.push(3), throwsStateError); }); }); }这组测试覆盖了 LIFO 语义、peek 的非破坏性、空栈边界、容量限制四个维度。我在实际开发中还会加一个泛型类型安全的测试:
test('泛型类型安全', () { final stack = Stack<double>(); stack.push(3.14); // 下面这行在编译期就报错,不会进入运行时: // stack.push('hello'); expect(stack.pop(), 3.14); });4. 在 Flutter for OpenHarmony 工程中集成与使用
4.1 三步接入鸿蒙 Flutter 项目
如果你的工程已经是标准的 Flutter for OpenHarmony 工程(一般是通过 hvigor 构建、Flutter 引擎基于 ohos 平台),那接入这个库非常简单。
第一步,把源码放到项目的lib/目录下,或者作为独立 package 放在packages/stack_lifo/路径中,在pubspec.yaml里添加依赖:
dependencies: flutter: sdk: flutter stack_lifo: path: packages/stack_lifo第二步,在需要使用的 Dart 文件里导入:
import 'package:stack_lifo/stack_lifo.dart';第三步,直接创建实例使用。就这么简单,不需要配置任何原生代码,也不需要写 platform channel。这个“零配置”特性是纯 Dart 库在鸿蒙上最大的优势。
4.2 典型使用场景一:表达式求值引擎
我最初写这个库,最直接的动机是在鸿蒙应用里做一个轻量的表达式计算引擎。比如用户输入类似(1 + 2) * (3 - 4)的表达式,需要先转成后缀表达式(RPN, Reverse Polish Notation),再用栈求值。
转后缀这一步就用到了 stack:
String infixToRpn(String expression, Map<String, int> precedence) { final output = StringBuffer(); final operators = Stack<String>(); for (final char in expression.split('')) { if (RegExp(r'[0-9.]').hasMatch(char)) { output.write(char); } else if (char == '(') { operators.push(char); } else if (char == ')') { while (operators.isNotEmpty && operators.peek() != '(') { output.write(operators.pop()); } operators.pop(); // 弹出 '(' } else { while (operators.isNotEmpty && precedence[operators.peek()] != null && precedence[operators.peek()]! >= precedence[char]!) { output.write(operators.pop()); } operators.push(char); } } while (operators.isNotEmpty) { output.write(operators.pop()); } return output.toString(); }这段代码里,operators.peek()返回String?类型,我用了!= null判断后再取值,这就是前面设计“空栈返回 null”带来的便利——不需要 try-catch,逻辑流畅很多。
4.3 典型使用场景二:页面状态回溯(撤销/重做)
另一个常见需求是撤销/重做。比如一个鸿蒙原生 + Flutter 混合开发的应用里,用户编辑文本或绘制图形后想撤销,这时候栈就是完美的容器。
class ActionHistory<T> { final Stack<T> _undoStack; final Stack<T> _redoStack; ActionHistory({int maxHistory = 100}) : _undoStack = Stack<T>(maxSize: maxHistory), _redoStack = Stack<T>(maxSize: maxHistory); void addAction(T action) { _undoStack.push(action); _redoStack.clear(); } T? undo() { final action = _undoStack.pop(); if (action != null) { _redoStack.push(action); } return action; } T? redo() { final action = _redoStack.pop(); if (action != null) { _undoStack.push(action); } return action; } }在这个实现里,maxSize参数直接解决了“用户撤销了 200 步、内存被撑爆”的问题。鸿蒙中低端设备的系统资源有限,这比自己去截断List优雅多了。
5. 常见问题与排查技巧实录
5.1 Flutter 构建报错:unable to find suitable visual studio toolc
很多 Windows 上开发 Flutter 的朋友会遇到这个经典报错。这个问题看起来跟鸿蒙无关,但它会在你配置 Flutter for OpenHarmony 环境时出现——因为你往往需要同时维护 Android / OpenHarmony 两套工具链。
报错信息长这样:
Flutter Windows build failed: unable to find suitable Visual Studio toolchain原因很简单:Flutter 在 Windows 上构建桌面端应用时,需要 Visual Studio 的 C++ 工作负载。如果你只装了 VS Code,没装 VS Build Tools,Flutter 就找不到 C++ 编译器。
解决办法是去 Visual Studio Installer 里添加“使用 C++ 的桌面开发”工作负载。装完之后重启终端,flutter doctor就应该能识别到 VS 了。
踩坑心得:装完 VS Build Tools 后一定要重启 VS Code / 终端,因为环境变量 PATH 不会自动刷新。这是 90% 情况下“明明装了还报这个错”的原因。
5.2 OpenHarmony 画面渲染异常与 stack 的关系
热搜词里出现“openharmony 画面渲染异常”,我怀疑很多朋友遇到了类似情况:在鸿蒙设备上跑 Flutter 应用,页面一卡一卡的,或者部分组件渲染不出来。这类问题往往排查到最后,根因不是渲染管线本身,而是主线程被大量计算阻塞了。
比如我在做表达式求值时,如果表达式特别长(几千字符),后端的栈操作是 O(n) 复杂度的,但如果表达式嵌套层次太深(极端情况下几万层),递归或用栈解析都会消耗大量 CPU。在鸿蒙设备上,这会直接把 UI 线程卡死。
解决方案是:CPU 密集型栈操作一律放到 Isolate 里执行。
final computeResult = await Isolate.run(() { final stack = Stack<int>(); // 在这里做大量计算 return stack.toList(); });这可能是标题里 stack 与渲染异常之间最隐含的联系。不是 stack 坏了导致渲染异常,而是对 stack 的滥用(在主线程做大量操作)导致 UI 卡顿,被误报为“渲染异常”。
5.3 Dart 空安全:pop 返回值类型是 T?
前面代码里,我的pop()返回类型是T?。这在 Dart 的 sound null safety 下,使用方直接调用stack.pop().toString()会报错,因为T?可能为 null,不能直接调方法。
正确姿势是:
final value = stack.pop(); if (value != null) { // 使用 value } else { // 栈为空,处理兜底逻辑 }经验分享:还有一次,我在鸿蒙设备上调试,发现一个 stack 的toList()返回的列表顺序跟预期相反。排查了半天,发现是我把toList()和toListFromBottom()搞混了。建议在代码里尽量少用toList()默认实现,而是明确在注释里写清返回顺序,避免使用方踩坑。
5.4 关于“you are applying flutter's main gradle plugin imperatively”这条报错
这个报错是工程结构问题,不是 stack 库的问题,但在 Flutter for OpenHarmony 环境中相当常见。它提示你在android/app/build.gradle里以命令式方式应用了 Flutter 的 Gradle 插件。OpenHarmony 的编译链路有时候会跟这个冲突。
常规做法是把:
apply plugin: 'com.flutter.gradle'改成:
plugins { id 'com.flutter.gradle' }改用 plugins DSL 方式声明。如果你不需要构建 Android 版本,这一步其实可以跳过,不影响 ohos 侧编译。但保险起见,最好还是改一下,因为两个平台的构建链有时会共享同一个工程结构模板。
写在最后的一点体会
这个 stack 库从写下第一行到跑通鸿蒙真机,前后也就一个下午。但它的意义不在于代码量,而在于它是整个鸿蒙适配链路里最容易验证的一块“试金石”——如果你能写一个纯 Dart 的库,在鸿蒙侧无缝跑通单元测试和实际调用,说明你的工程基础架构已经没问题了。
最后再分享一个小技巧:如果你后续要写更复杂的算法库(比如二叉树、图、动态规划状态机),尽量保持同样的“纯 Dart + 零依赖 + 边界明确”风格。这种情况下,一套代码几乎可以在 Android、iOS、Web、OpenHarmony 上通用,省掉的是你未来几十倍的时间。