如果你要做一个需要同时跑在手机、平板甚至PC上的交互型应用,Flutter基本是绕不开的选项。我最近用Flutter做了一款算法可视化应用,核心功能就是把排序、查找这类算法的运行过程用动画呈现出来,同时要求它能跑在鸿蒙设备上。断断续续搞了两周,整体比预想中顺利,但环境适配和渲染性能上踩了不少坑。这篇文章就把完整的设计思路、关键代码和鸿蒙适配细节一次说清楚,给正在做类似项目的朋友一个可参考的落地路径。
这篇文章适合谁?一类是准备把Flutter项目跑上鸿蒙的移动端开发,另一类是想用动画方式理解算法的前端或客户端工程师。就算你还没碰过鸿蒙,只要熟悉Flutter基础,也能在文里找到能直接用的代码片段。整个项目的核心其实不复杂:算法负责生成“步骤”,渲染层负责播放“步骤”,两者彻底解耦,后面加新算法、调动画都会非常省力。
1. 项目定位与整体设计思路
1.1 算法可视化应用到底在做什么
算法可视化不是“画几个柱子然后动一动”那么简单。它要解决的核心问题是:把算法的执行过程转换成用户能理解、能感知的视觉信号。以排序为例,用户能看到的至少包括数据的初始状态、每一轮比较时涉及的关键元素、两个元素发生交换的瞬间、已经排好序的位置、当前执行到哪一步。这五个信息缺一个,动画就少了说服力。
如果你只是想演示冒泡排序,可能画几根柱子就够了。但如果你要在这个应用里放快排、归并、堆排序甚至图算法,就必须抽象出一套通用机制,让任何算法都能“播报”自己的执行过程。文章里的项目最终支持了5种排序算法和一种迷宫生成算法,全部共用同一套动画播放框架。这个抽象就是整个项目最核心的部分。
1.2 为什么选Flutter而不是ArkTS或KMP
这个项目最初摆在面前有三条路:
- 用ArkTS/ArkUI做鸿蒙原生应用。生态干净,调试方便,但未来要上Android和iOS就等于完全重写。
- 用KMP(Kotlin Multiplatform)共享逻辑,UI各端分别实现。逻辑能复用一部分,但UI工作量仍然翻倍。
- 用Flutter写跨平台UI,通过社区适配层跑鸿蒙。一套Dart代码覆盖Android、iOS、鸿蒙,UI完全一致。
考虑到算法可视化对UI交互要求很高,而且动画逻辑、状态管理、绘制引擎都是核心工作量,KMP只复用算法逻辑帮不上太多忙。Flutter的CustomPainter自绘引擎对这种自定义界面有天然优势——整个柱状图区域不用依赖平台控件,我可以完全控制每个像素,换到任何平台渲染结果都一样。而ArkTS原生方案最大的问题是锁死单平台。
从工程效率角度,Flutter还有一个突出优势:热重载。算法可视化应用里最烦人的就是调动画细节,改一个动画参数、颜色、时长,热重载立刻能看到效果,这个效率差距在开发阶段非常明显。
1.3 功能清单与页面规划
在动手写代码前,我把功能收敛成几个必须项:
- 算法选择:冒泡、插入、选择、快速、归并五种排序算法。
- 数据规模调整:10到200个数据点,对应柱状图数量。
- 播放控制:播放、暂停、单步前进、复位、速度调节。
- 高亮反馈:比较中的两个元素、本轮已确定的元素有不同的颜色。
- 状态展示:当前执行的操作类型、轮次、比较次数。
页面结构也刻意保持简单:一个主页面放柱状图区域,顶栏放控制按钮,底部一个算法选择器。更多花哨功能(比如录制导出、代码对照)都列为后续迭代。我的原则是先把主流程做完整、做流畅,再考虑附加价值。
1.4 为什么“鸿蒙”是这个项目的主要验证场景
“鸿蒙”在这个项目里不只是一个加分标签,而是真实的交付场景。很多团队现在需要在鸿蒙设备上提供与现有Android/iOS一样的体验,但原生技术栈不互通。Flutter是少数能保持UI一致性的方案。
但这里必须说清楚:Flutter官方主分支对鸿蒙的支持目前并不像Android/iOS那样开箱即用,需要借助OpenHarmony社区维护的Flutter引擎适配分支。这个分支和官方Flutter版本存在一定耦合,不能随手升级。所以选版本的第一原则是“固定一套已经验证过的组合”,而不是盲目追新。后面章节会给出具体的版本组合和安装步骤。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 开发环境准备:Flutter鸿蒙开发工具链搭建
2.1 关键工具与版本匹配
我这次最终跑通的环境组合如下,这是经过实测可用的版本组合:
| 组件 | 推荐版本 | 备注 |
|---|---|---|
| DevEco Studio | 4.0及以上 | 鸿蒙IDE,用于构建hap包 |
| OpenHarmony SDK | API 9及以上 | DevEco内置可下载 |
| Flutter SDK | 3.x(建议3.7系列) | 配合鸿蒙分支版本使用 |
| flutter_flutter_harmony | 与Flutter版本匹配 | 社区维护的引擎分支 |
| flutter_ohos | 对应flutter版本 | 鸿蒙插件适配层 |
| Node.js | 16以上 | ohpm依赖解析需要 |
版本匹配这块最容易被坑。建议去flutter_flutter_harmony的release页看它明确标注支持哪个Flutter版本,然后固定使用。我见过不少人装了最新的Flutter版本,结果适配层还没跟上,Dart API报一堆错,最后只能降级。所以第一步永远是选定组合,而不是装最新。
2.2 一步步让Flutter跑在鸿蒙上
具体接入流程我整理如下:
- 安装DevEco Studio,首次启动后下载OpenHarmony SDK,配置好
hdc工具路径。 - 获取flutter_flutter_harmony分支,作为你的Flutter SDK。比如在本地单独建一个
sdk/flutter_ohos目录,拉取对应分支。 - 配置环境变量:
FLUTTER_ROOT指向上面这个目录;PATH加入bin;OHOS_SDK_HOME指向DevEco里的SDK目录。注意不要和原来Flutter稳定版混用。 - 用这个Flutter创建项目:
flutter create --platforms ohos,android,ios algo_visual。 - 在pubspec.yaml里添加flutter_ohos适配依赖,版本要和Flutter分支匹配。
- 在项目根目录执行
flutter run -d ohos,首次会编译生成hap并安装到模拟器或真机。如果没有直接支持,也可以先在DevEco里打开生成的ohos目录,用DevEco构建hap,再通过hdc安装。
我看到很多人卡在第五步。如果执行flutter run提示找不到ohos平台,基本是FLUTTER_ROOT指向不对,或者flutter_ohos插件版本不匹配。这两个问题占环境问题的八成。
2.3 环境层面的常见坑
模拟器问题:目前鸿蒙模拟器镜像基本只有arm64版本,如果你的开发机是x86架构,模拟器根本启动不了,只能找一台arm64的机器,或者直接用鸿蒙真机。这个限制在官方文档里有说明,但很多人不看就白折腾了半天。
编译慢不是bug:第一次跑鸿蒙构建要下载OpenHarmony依赖,国内网络环境下有时候会卡在gradle或ohpm依赖下载。建议先把构建缓存下载完整,之后增量编译就快很多。不要一卡就中断,等一两分钟是正常的。
Dart SDK版本不一致:如果你机器上原来装了其他Flutter版本,很容易出现dart命令还是老版本的情况。排查方式是在项目目录执行flutter doctor,看显示的Dart版本是否和你配置的FLUTTER_ROOT一致。
3. 算法可视化核心架构:把算法变成动画
3.1 核心思想:算法与渲染彻底解耦
算法可视化最容易写崩的地方,是把算法逻辑和UI刷新写在一起。比如冒泡排序里每比较一次就直接调setState改UI,这样做demo还行,一旦要支持暂停、单步、反向回放,代码根本没法维护。
我的做法是引入一个“步骤快照”概念:算法本身不再直接驱动UI,而是每执行一个有意义的动作,就往一个列表里追加一条记录。UI层只负责“播放”这个记录列表。这个设计跟视频播放器很像——录像是预先生成的,播放器只是按时间轴展示。好处是整个动画流程变成一个纯数据流问题,暂停、恢复、调速、单步都只是控制下标移动,不需要关心算法内部。
这种做法还有一个额外收益:同一套算法执行器可以跑在后台Isolate里生成步骤,不会阻塞UI线程。对几百个数据点的排序,步骤可能上万,但因为是预生成,用户交互仍然很流畅。
3.2 步骤快照与播放控制器的数据结构
我定义的数据结构长这样:
dart复制enum StepAction {
compare, // 正在比较两个元素
swap, // 交换两个元素
setSorted, // 标记一个位置已排好序
done, // 整个算法结束
}
class StepRecord {
final StepAction action;
final int indexA;
final int indexB;
final List<int> currentData;
StepRecord({
required this.action,
required this.indexA,
required this.indexB,
required this.currentData,
});
}
这里有个比较纠结的点:currentData到底是存整个数组的快照,还是只存“变化信息”?如果数据规模只有几十个,整个快照完全无所谓;到了200个元素、上万个步骤,全量快照会占用不少内存。但考虑到实际场景里200个元素已经足够演示,而且全量快照让播放器实现极其简单(每次只需要替换整个数组),我最后还是选择了全量快照。如果你要做几千个数据点的可视化,建议改成只记录diff,播放时逐帧应用diff。
播放控制器用一个ChangeNotifier来驱动,核心字段如下:
dart复制class PlaybackController extends ChangeNotifier {
final List<StepRecord> steps;
int currentIndex = 0;
bool isPlaying = false;
StepRecord get current => steps[currentIndex];
void play() {
isPlaying = true;
notifyListeners();
}
void pause() {
isPlaying = false;
notifyListeners();
}
void next() {
if (currentIndex < steps.length - 1) {
currentIndex++;
notifyListeners();
}
}
void prev() {
if (currentIndex > 0) {
currentIndex--;
notifyListeners();
}
}
void reset() {
currentIndex = 0;
notifyListeners();
}
}
UI层只需要监听controller,每次变化后用current里的数据重绘柱状图。动画内部怎么处理两个元素的交换,反而是渲染层的事。
3.3 为什么不能边跑算法边更新UI
你可能觉得上面那套太绕,直接for循环里setState不是更简单吗?我先踩过的版本就是这样的。真实遇到三个问题:
- 暂停没法做。如果算法已经在一个循环里跑完,你在UI层临时设个标志位,算法循环里根本不会检查。
- 步进没法做。你只能从头跑到尾,想看某一步的状态非常麻烦。
- 高频刷新卡顿。循环里每比较一次就setState一次,一秒钟可能刷新几百次,UI线程被塞满,动画看起来像在爬。
所以我现在看到所有教学代码里出现“算法循环中调setState”都会下意识皱眉。步骤快照加播放控制器的架构虽然多写一点代码,但后面加功能、加算法都非常省事。
4. 关键代码实现与实操要点
4.1 排序算法执行器:以快速排序和归并排序为例
算法执行器的职责只有一个:把算法过程“翻译”成StepRecord。
以快速排序为例,我写了一个递归版本,用async函数往列表里追加记录:
dart复制Future<void> quickSort(List<int> arr, int low, int high) async {
if (low >= high) return;
int pivotIndex = await partition(arr, low, high);
await quickSort(arr, low, pivotIndex - 1);
await quickSort(arr, pivotIndex + 1, high);
}
Future<int> partition(List<int> arr, int low, int high) async {
int pivot = arr[high];
int i = low - 1;
for (int j = low; j < high; j++) {
records.add(StepRecord(
action: StepAction.compare,
indexA: j,
indexB: high,
currentData: List.of(arr),
));
if (arr[j] < pivot) {
i++;
swap(arr, i, j);
records.add(StepRecord(
action: StepAction.swap,
indexA: i,
indexB: j,
currentData: List.of(arr),
));
}
}
swap(arr, i + 1, high);
records.add(StepRecord(
action: StepAction.swap,
indexA: i + 1,
indexB: high,
currentData: List.of(arr),
));
records.add(StepRecord(
action: StepAction.setSorted,
indexA: i + 1,
indexB: high,
currentData: List.of(arr),
));
return i + 1;
}
为了让每次快照都是不可变数据,currentData里传了List.of(arr)复制。这也说明之前提到的内存问题确实存在,但在数据量不大的前提下,代码清晰更重要。
归并排序的实现会稍微复杂一点,因为它需要临时数组。我的做法是额外维护一个workingList,记录合并过程中每个位置的值变化。可视化的时候可以用两个颜色区分“左边已排好”和“右边待处理”,观众看到的是一个逐渐归并的过程,教学效果好于快排。
这里有一个纯Dart的异步技巧:由于生成步骤的函数里用了await但实际没有真正的异步操作,这些async函数会同步执行到第一个await。所以步骤生成是在同一事件循环里完成的,不会产生并发问题。真正做实时生成时,可以加一个await Future.delayed(Duration.zero)让出事件循环,不过为了简化,预生成模式不需要。
4.2 自定义Painter绘制柱状图与动画插值
柱状图绘制我用的是CustomPainter,核心实现如下:
dart复制class ChartPainter extends CustomPainter {
final List<int> data;
final int highlightA;
final int highlightB;
final int sortedCount;
ChartPainter({
required this.data,
this.highlightA = -1,
this.highlightB = -1,
this.sortedCount = 0,
});
@override
void paint(Canvas canvas, Size size) {
if (data.isEmpty) return;
double maxVal = data.reduce((a, b) => a > b ? a : b).toDouble();
double barWidth = size.width / data.length;
for (int i = 0; i < data.length; i++) {
double barHeight = size.height * data[i] / maxVal;
Rect barRect = Rect.fromLTWH(
i * barWidth,
size.height - barHeight,
barWidth - 1,
barHeight,
);
Paint barPaint = Paint();
if (i == highlightA || i == highlightB) {
barPaint.color = const Color(0xFFFF6B6B); // 高亮
} else if (i >= data.length - sortedCount) {
barPaint.color = const Color(0xFF4ECDC4); // 已完成
} else {
barPaint.color = const Color(0xFF45B7D1); // 默认
}
canvas.drawRect(barRect, barPaint);
}
}
@override
bool shouldRepaint(covariant ChartPainter oldDelegate) {
return oldDelegate.data != data ||
oldDelegate.highlightA != highlightA ||
oldDelegate.highlightB != highlightB ||
oldDelegate.sortedCount != sortedCount;
}
}
交换动画插值是我后面加上的。做法很简单:当播放器播放到一个swap步骤时,UI不要立刻把两根柱子替换位置,而是记录“旧位置”和“新位置”,用一个AnimationController执行线性插值,让柱子平滑移动到目标位置。这一步对视觉体验的提升非常明显,观众能很直观地看到“交换”过程。
注意CustomPainter的shouldRepaint一定要认真写。如果返回true,每帧都会重绘,但数据没变化时返回false能省掉大量GPU开销。我的做法是全部比对字段,任一变化才重绘。
4.3 播放、暂停、单步与速度调节的实现
播放器用Timer.periodic驱动:
dart复制void startAutoPlay() {
timer?.cancel();
timer = Timer.periodic(Duration(milliseconds: speedMs), (timer) {
if (controller.currentIndex >= controller.steps.length - 1) {
timer.cancel();
controller.pause();
return;
}
controller.next();
});
}
速度调节只需要把speedMs改成新值然后重启定时器。单步功能就是调用controller.next(),暂停就是timer?.cancel()。
UI层用AnimatedBuilder监听controller来重建CustomPaint:
dart复制AnimatedBuilder(
animation: controller,
builder: (context, _) {
return CustomPaint(
painter: ChartPainter(
data: controller.current.currentData,
highlightA: controller.current.indexA,
highlightB: controller.current.indexB,
),
size: Size.infinite,
);
},
)
这里有个小经验:AnimatedBuilder只在controller通知时才重建,而controller只在步骤切换时通知,所以每帧重建的成本是可控的。不要在paint里做复杂计算,尤其不要在里面调用data.reduce这种遍历整数组的操作。数据量大的时候,可以把最大值缓存起来,每次生成快照时一起传过来。
5. 三端运行验证与鸿蒙适配踩坑记录
5.1 同一套代码在三端的实测表现
完成主体功能后,我在Android、iOS和鸿蒙真机上分别跑了同一套代码。对比结果如下:
| 平台 | 运行流畅度 | 启动时间 | 需要修改的代码量 |
|---|---|---|---|
| Android | 满帧,稳定60fps | 1s以内 | 0 |
| iOS | 满帧,稳定60fps | 1s以内 | 0 |
| HarmonyOS | 主要操作满帧,复杂动画偶尔掉到30fps | 1-2s | 少量平台判断 |
能做到三端几乎零修改,靠的是Flutter自绘引擎。柱状图、高亮、按钮样式全部由Dart侧控制,没有依赖任何原生控件。鸿蒙上的性能略差一点,主要是适配层还没有完全优化到位,但算法可视化场景完全可接受——用户看的是动画逻辑,不是游戏帧率。
需要注意的差异点是:鸿蒙上默认字体渲染和Android有细微区别,如果你给数字加了图标字号,在鸿蒙上可能偏大。解决办法是不要用固定字号,用Flexible和FittedBox自适应。这个问题不只在鸿蒙上出现,跨平台UI一直都有。
5.2 鸿蒙平台典型问题与排查过程
我按实际遇到的频率排个序:
第一,flutter_ohos版本和Dart SDK版本不匹配。现象是编译报错,一堆undefined class或者method not found。排查方法很简单:看报错里涉及的API是不是在你Flutter版本里已经改过名。我遇到的是withOpacity替换成withValues(alpha:)的问题,旧写法在派生的Flutter分支里找不到。解决方向有两个:要么降低Flutter分支到旧API,要么改代码适配新API。考虑到稳定性,我当时选择了后者。
第二,运行到鸿蒙模拟器时报“code 3”或设备不支持错误。主要是模拟器镜像只支持arm64,而开发机是x86。这个只能换真机或者借用arm64机器测试,没有代码层面的解法。
第三,构建产物是hap,但DevEco和命令行hdc安装有时候状态不同步。我遇到过flutter run提示安装成功但桌面没图标的情况,用DevEco清理一次构建缓存再重新构建就能解决。
第四,自定义Painter在鸿蒙上偶发绘制闪一下的问题。排查下来是RepaintBoundary没有被正确识别,某些页面切换时绘制层被回收。解决方案是在CustomPaint外包一层RepaintBoundary,并在页面级别设置Isolate渲染配置。这个问题只在早期的鸿蒙适配版本中出现,新版本已经很少见。
5.3 常见问题速查表
| 现象 | 原因 | 解决方案 |
|---|---|---|
| flutter run找不到ohos设备 | FLUTTER_ROOT指向了原生Flutter | 切到flutter_flutter_harmony分支,重新设置环境变量 |
| 编译报Dart API不存在 | 适配分支版本和Flutter版本不匹配 | 查阅flutter_flutter_harmony release页,固定版本组合 |
| 模拟器启动失败 | 模拟器镜像仅支持arm64 | 使用arm64宿主机或真机 |
| 安装hap成功但桌面无图标 | DevEco构建缓存异常 | DevEco清理构建缓存,重新构建 |
| CustomPaint偶发闪烁 | RepaintBoundary绘制层被回收 | 外层包RepaintBoundary,升级适配版本 |
| 动画看起来卡顿 | shouldRepaint频繁重绘 | 完善shouldRepaint比对逻辑,缓存最大值等中间结果 |
这些坑多数不是算法可视化本身的问题,而是“Flutter跑在鸿蒙”这套新组合的生态磨合成本。随着适配层更新,很多问题会自己消失。我的建议是遇到问题先查flutter_flutter_harmony的issue列表,大概率已经有人遇到过。
再补充一个开发技巧:算法可视化应用的调试真的离不开单步功能。我开发时在调试模式默认打开“单步模式”,配合Dart的debugger,可以看到每一步数据变化,比打印日志高效太多。这个习惯也延续到了写业务代码时,凡是涉及状态流转的功能,我都会先做一个可步进的回放器。
最后说一下后续扩展方向:这个项目的步骤快照机制其实可以进一步抽象成通用动画引擎,不只用于排序算法,还能用于图遍历、动态规划DP数组的变化展示。如果你想做一个教学类的算法平台,这套架构可以直接复用。希望这次分享能帮你少踩几个坑,祝你的Flutter鸿蒙之旅顺利。
