最近在把游戏集合App往OpenHarmony设备上迁移,五子棋是第一个落地的主功能模块。项目本身是Flutter写的,Android和Linux上跑得很顺,换到OpenHarmony这边,我最初以为就是换个Device编译一下的事,实际动手才发现,光是棋盘绘制这一个模块,就牵出了Flutter自绘、坐标转换、图层缓存、手势命中一整条技术线。这篇文章就以五子棋棋盘绘制为主线,把从OpenHarmony版Flutter环境搭建,到棋盘网格计算、CustomPainter绘制、落子交互的实现细节,以及真机调试中遇到的坑,全部摊开来写。准备做Flutter自绘界面,或者想把游戏类界面迁到OpenHarmony的开发者,可以直接拿这份经验当参考。
1. 项目背景与技术选型分析
1.1 为什么我选择Flutter + OpenHarmony
先交代一下项目背景。这个游戏集合App是团队维护了很久的Flutter项目,里面已经有五子棋、跳棋、数独、扫雷好几个模块,UI层全部用Dart写的,公共组件库、主题系统、音效管理都沉淀得很完整。之前只跑Android和Linux桌面,后来客户提了需求,要支持OpenHarmony设备,而且主要是RK3568、RK3588这类开发板,屏幕大小从7寸到15寸不等。
选Flutter而不是另起一套ArkUI原生实现,原因很直接:第一,整个UI层可以继续复用,五子棋、数独这些游戏界面没有什么平台强相关的东西,只要Flutter能在OpenHarmony上跑起来,游戏逻辑和绘制代码完全不用动;第二,Flutter的自绘渲染模型天生适合做游戏UI,五子棋要画网格、画棋子、做落子动画,在Widget树里堆组件反而别扭;第三,团队对Dart和Flutter的熟悉程度远高于ArkUI,迁移成本最低。
这里需要明确一个概念:OpenHarmony上的Flutter不是官方Flutter分支,而是OpenHarmony SIG组维护的fork版本,核心仓库是flutter_flutter和flutter_engine,通过ArkUI组件作为渲染承载层接入OpenHarmony系统。这意味着SDK本身、构建工具链和一些底层能力跟官方版本有差异,不能直接用官方flutter命令去编译OpenHarmony应用,后面2.1节会细说。但从开发者视角看,大部分Flutter API是兼容的,CustomPainter这类自绘接口的调用方式也基本一致,所以游戏UI层的跨端复用是成立的。
1.2 棋盘绘制:自绘方案还是组件嵌套
确定技术栈之后,第一个要拍板的是棋盘UI怎么做。五子棋棋盘有15条横线、15条竖线、225个交叉点落子位,加上天元和四个星位小圆点。这个量级不算大,用Widget拼也不是不行:做一个Stack,用Positioned把每一条线画成Container,再把每个交叉点摆上GestureDetector。但我在项目里没有这么做,原因不光是性能,还有代码可维护性。
如果走Widget嵌套方案,一个15路棋盘光横竖线条就要30个Container,再加上星位、棋子、交互响应,界面树会非常臃肿。每一帧状态刷新时都要经过Widget rebuild,在RK3568这类中低端设备上,界面树深度一大,帧率波动肉眼可见。而且后面如果要做棋盘尺寸切换(13路、19路)、棋盘主题切换、棋盘旋转,Widget方案改起来是灾难性的。
自绘方案就清爽得多。棋盘本质上是一张固定内容的图,棋子是叠加在上面的图形元素。用CustomPaint包一个CustomPainter,在paint方法里依次画背景、网格线、星位、棋子,所有视觉单元都在同一个Canvas坐标空间里,坐标计算统一、重绘逻辑可控、也不存在Widget树膨胀的问题。后面我还会在4.3节单独说一个关键优化:把静态棋盘层(背景+网格+星位)提前预渲染成Picture缓存,重绘时直接贴图,这个方案在OpenHarmony上实测帧率改善非常大。
所以最终选定:棋盘层和棋子层全部走CustomPainter自绘,手势命中通过Canvas坐标反推交叉点,不依赖任何Widget级的事件绑定。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与工程搭建
2.1 获取OpenHarmony版Flutter SDK
在OpenHarmony上跑Flutter,第一步不是创建工程,而是装一套正确的SDK。我一开始偷懒,直接用了Android那边的官方Flutter SDK,结果flutter doctor检查OpenHarmony环境时直接不认设备,编译产物也不是OpenHarmony需要的结构,白折腾了一晚上。后来老老实实按官方文档拉OpenHarmony SIG维护的flutter_flutter仓库,才把链路走通。
目前的推荐流程是:从OpenHarmony官方资料站点获取对应版本的Flutter SDK压缩包,解压到本地,把bin目录加进PATH,再用flutter doctor确认环境。OpenHarmony版SDK的版本号策略跟官方Flutter会有差异,比如官方是3.16.9,OpenHarmony侧可能是基于某个上游版本打补丁的独立发布版,下载时要注意和目标设备系统版本匹配,不要盲目追新。除了Flutter SDK本身,还需要安装DevEco Studio和配套的OpenHarmony SDK,因为Flutter工程最终会生成一个HarmonyOS工程(ohos目录),需要用它来编译打包HAP。
一个容易踩的坑是环境变量。OpenHarmony版flutter命令和官方flutter命令如果同时存在,PATH里先匹配到谁就会用谁。我是把OpenHarmony版SDK的bin目录放在PATH最前面,并且给终端单独设了别名,避免误用。另一个坑是镜像配置。国内网络环境下,直接拉取Flutter GitHub仓库或pub.dev依赖经常超时,需要在用户目录配置PUB_HOSTED_URL和FLUTTER_STORAGE_BASE_URL这两个环境变量,指向可用的镜像源,后面下载依赖和编译资源都会顺畅很多。
安装完成后,可以用flutter doctor -v看所有依赖项状态,重点确认Flutter、DevEco Studio、OpenHarmony SDK三项都是绿色。我记得当时OpenHarmony插件检查项显示的是“Not available”,查了资料才发现是因为没有安装DevEco Studio里的OHOS SDK,装好之后重启flutter doctor就正常了。
2.2 创建项目并在RK3568开发板部署
SDK就绪后,创建项目跟官方Flutter流程基本一致:flutter create命令生成工程,然后需要在工程根目录执行一次专门的命令,让Flutter生成OpenHarmony平台目录,通常是ohos文件夹。这一步千万别漏,没有ohos目录的话,后面DevEco Studio根本打不开工程。
工程结构长这样:lib目录放Dart代码,ohos目录是HarmonyOS工程的根,里面包含entry模块、build-profile.json5、hvigor配置等。日常开发我大部分时间在lib目录写Dart代码,只有在需要配置签名、版本号、权限的时候才去动ohos目录。
跑真机首选RK3568开发板,过程中每一步都有坑。开发板通过USB连电脑,先确认设备被识别:hdc list targets,能列出设备序列号就说明连接正常。如果列不出来,检查开发板有没有开启USB调试模式,换数据线、换USB口也要试,有的开发板对USB口供电很敏感。连接正常后,用hdc shell param get const.product.name可以查看当前系统版本信息,确认OpenHarmony版本和SDK匹配。
打包部署的顺序是:先在ohos目录用DevEco Studio打开工程,配置签名(OpenHarmony应用需要签名才能安装),然后回到flutter命令执行flutter build hap --release或者通过DevEco Studio直接Run。最终产物是一个HAP包,用hdc install安装到开发板上。hdc命令和adb命令风格很像,常用几招总结一下:
| 操作 | 命令 |
|---|---|
| 查看设备列表 | hdc list targets |
| 进入设备shell | hdc shell |
| 查看系统版本 | hdc shell param get const.product.name |
| 安装应用 | hdc install xxx.hap |
| 卸载应用 | hdc uninstall 包名 |
| 推送文件 | hdc file send 本地路径 设备路径 |
我实际遇到的部署问题多数集中在签名不一致上。OpenHarmony系统严格校验应用签名,用DevEco Studio的自动签名配置通常能绕过一大半问题,但有些定制系统要求使用系统签名,这时候就得找设备厂商要对应的签名文件,没有它hdc install会报签名校验失败,而且是安装阶段直接失败,日志提示也未必直白,需要多看ohos目录下编译输出的日志。
3. 棋盘布局与尺寸计算
3.1 15路棋盘的坐标体系
五子棋棋盘选15路是标准做法,也就是15条横线、15条竖线,形成14×14个方格,交叉点总数是225个。绘制棋盘的第一步是把这套网格坐标映射到控件像素坐标,这个映射关系直接决定了后面所有绘制和手势逻辑的复杂度。
先定义几个基础概念:gridSize=15表示每边15条线,cellSize表示相邻两条线之间的像素距离,leftMargin和topMargin分别表示棋盘左上角第一条线距离控件左边界和上边界的距离。棋盘最外侧的线不能贴边画,要在四周留出相等的留白,否则视觉上会显得拥挤,棋子也会超出控件范围。
核心计算公式是这样的:棋盘实际网格区域的总边长 = cellSize × (gridSize - 1)。如果控件尺寸是size,希望四周留白为总宽度的5%,那么:
code复制cellSize = (size.shortestSide * (1 - 2 * edgeRatio)) / (gridSize - 1)
这里用shortestSide是为了保证在非正方形控件里,棋盘不会超出边界。leftMargin和topMargin分别按宽高计算:
code复制leftMargin = (size.width - cellSize * (gridSize - 1)) / 2
topMargin = (size.height - cellSize * (gridSize - 1)) / 2
只要cellSize一致,左右留白自然相等,上下留白也自然相等,视觉上棋盘是居中的。这套坐标计算我封装成了一个BoardGeometry类,后面CustomPainter和手势处理都复用它,代码不用到处重复算坐标。
dart复制class BoardGeometry {
final int gridSize;
final double cellSize;
final double leftMargin;
final double topMargin;
BoardGeometry(Size size, {this.gridSize = 15, double edgeRatio = 0.05})
: cellSize = (size.shortestSide * (1 - 2 * edgeRatio)) / (gridSize - 1),
leftMargin = (size.width - cellSize * (gridSize - 1)) / 2,
topMargin = (size.height - cellSize * (gridSize - 1)) / 2;
Offset offsetFor(int row, int col) =>
Offset(leftMargin + col * cellSize, topMargin + row * cellSize);
int? positionToIndex(Offset position) {
final row = ((position.dy - topMargin) / cellSize).round();
final col = ((position.dx - leftMargin) / cellSize).round();
if (row < 0 || row >= gridSize || col < 0 || col >= gridSize) {
return null;
}
return row * gridSize + col;
}
}
offsetFor用于给定行列号拿像素坐标,画网格线和棋子都要用到;positionToIndex正好反过来,手势点击时把像素坐标换算成行列号。这两个方法是一对互逆操作,是整个棋盘交互的地基。
3.2 适配不同屏幕方向
如果你只在单一尺寸的开发板上调试,用上面的计算方式就够。但游戏集合App是要适配多种屏幕的,开发板、平板、甚至横竖屏切换都有可能。这时候直接用size.shortestSide会出问题:横屏时shortestSide是高度,竖屏时是宽度,虽然棋盘不会越界,但棋盘在非正方形控件里永远是贴着短边排布,长边方向会留下两片空白,观感不好。
我的做法是把棋盘区域从整个屏幕布局里独立出来,用一个AspectRatio控件把棋盘约束成正方形,再让CustomPaint填满整个区域。这样CustomPainter里的size永远是正方形,几何计算可以更简单,所有尺寸直接用side或width即可,不用再区分宽高。
dart复制Center(
child: AspectRatio(
aspectRatio: 1.0,
child: CustomPaint(
painter: BoardPainter(
geometry: _geometry,
stones: _board,
lastMove: _lastMove,
),
),
),
)
用AspectRatio的好处还不止是适配简单。棋盘本身就是正方形,把它从页面布局中独立出来后,四周可以自由放玩家信息、操作按钮、比分牌,整个游戏页面的布局结构更清晰。同时,CustomPainter在build时传入的geometry需要按实际渲染大小重新计算,我通常放在LayoutBuilder的builder里,根据constraints拿到实际尺寸,再构造BoardGeometry,这样无论屏幕怎么变,棋盘都跟着变。
这里补充一个很多人忽略的点:CustomPaint的size如果不显式指定,会取父级在布局阶段给定的尺寸,但如果父级传入的约束是松散的(比如Center允许子级任意大小),CustomPaint的默认尺寸是0×0,棋盘就消失不见了。所以CustomPaint外面套的约束必须能给出确定尺寸,AspectRatio + Center这条路是最稳的。
4. CustomPainter核心绘制
4.1 网格线与星位
棋盘绘制的核心都在CustomPainter的paint方法里。整体绘制顺序是背景、网格线、星位、棋子,按从底层到上层的顺序进行,这个顺序不能乱,否则视觉层次就错了。
背景我用的是一个纵向渐变填充的圆角矩形,颜色从浅棕到深木色过渡,模拟木质棋盘质感。RoundedRect的圆角半径取6到8像素就够,太大反而失真。
dart复制@override
void paint(Canvas canvas, Size size) {
_drawBackground(canvas, size);
_drawGridLines(canvas);
_drawStarPoints(canvas);
_drawStones(canvas);
}
网格线绘制是15条横线和15条竖线。直接用drawLine逐条画是最直观的,但更高效的做法是把所有横线和竖线合并成一条Path,一次drawPath搞定。Path内部会做批处理,比多次drawLine减少状态切换开销。
dart复制void _drawGridLines(Canvas canvas) {
final paint = Paint()
..color = const Color(0xFF5B4B33)
..strokeWidth = 1.2
..style = PaintingStyle.stroke
..isAntiAlias = true;
final path = Path();
for (int i = 0; i < geometry.gridSize; i++) {
final offset = geometry.offsetFor(0, i);
path
..moveTo(offset.dx, geometry.topMargin)
..lineTo(offset.dx, geometry.topMargin + geometry.cellSize * (geometry.gridSize - 1));
}
for (int i = 0; i < geometry.gridSize; i++) {
final offset = geometry.offsetFor(i, 0);
path
..moveTo(geometry.leftMargin, offset.dy)
..lineTo(geometry.leftMargin + geometry.cellSize * (geometry.gridSize - 1), offset.dy);
}
canvas.drawPath(path, paint);
}
注意这里横向和纵向line的边界值都用棋盘网格的总边长去算,不要直接用size.width和size.height,否则线条会把边距也画出来,跟棋盘外围的留白冲突。
星位是标志性的小圆点,15路棋盘有5个:天元在正中心,四个星位分别在第3行第3列、第3行第11列、第11行第3列、第11行第11列。注意这里说的行列是从0开始数的,换算成人话就是每边往里数第3个和第11个交叉点。实际绘制时半径取3到4像素,颜色深棕即可。
dart复制void _drawStarPoints(Canvas canvas) {
final paint = Paint()
..color = const Color(0xFF5B4B33)
..style = PaintingStyle.fill
..isAntiAlias = true;
final starRows = [3, 11];
for (final row in starRows) {
for (final col in starRows) {
canvas.drawCircle(geometry.offsetFor(row, col), 3.5, paint);
}
}
canvas.drawCircle(geometry.offsetFor(7, 7), 3.5, paint);
}
4.2 棋子绘制
棋子的绘制质量直接影响游戏观感。如果只是画两个纯色圆,那跟儿童简笔画没区别,玩家看一眼就觉得糙。我在实际项目里给棋子加了阴影、径向渐变和高光三层,黑棋和白棋的渐变方向略有差异。
先说阴影。Flutter里Canvas没有内置投影API,做阴影最简单的方式是在棋子的右下偏移位置画一个半透明黑色圆,偏移量通常是2到3像素,透明度0.2左右。这个技巧比用MaskFilter做模糊阴影性能好很多,在OpenHarmony的中低端设备上尤其重要,避免每帧都做高斯模糊级别的计算。
主体填充用径向渐变,渐变原点偏向左上,让球体有受光感。黑棋的渐变色从深灰过渡到近黑,白棋从纯白过渡到浅灰。给白棋和黑棋各自定义一组颜色就好。
dart复制void _drawStone(Canvas canvas, Offset center, double radius, StoneColor color, {bool isLast = false}) {
final shadowPaint = Paint()
..color = Colors.black.withOpacity(0.25)
..isAntiAlias = true;
canvas.drawCircle(center + Offset(2.5, 2.5), radius, shadowPaint);
final (lightColor, darkColor) = color == StoneColor.black
? (const Color(0xFF6B6B6B), const Color(0xFF1A1A1A))
: (const Color(0xFFFFFFFF), const Color(0xFFBDBDBD));
final rect = Rect.fromCircle(center: center, radius: radius);
final gradient = RadialGradient(
center: const Alignment(-0.4, -0.3),
radius: 1.0,
colors: [lightColor, darkColor],
);
final fillPaint = Paint()
..shader = gradient.createShader(rect)
..isAntiAlias = true;
canvas.drawCircle(center, radius, fillPaint);
// 高光,做一个小的半透明椭圆,模拟顶部反光
final highlightPaint = Paint()
..color = Colors.white.withOpacity(0.35)
..isAntiAlias = true;
canvas.drawOval(
Rect.fromCenter(
center: center - Offset(radius * 0.3, radius * 0.3),
width: radius * 0.8,
height: radius * 0.5,
),
highlightPaint,
);
// 最后一手标记,在棋子上画一个小红圈
if (isLast) {
final lastPaint = Paint()
..color = const Color(0xFFCC4444)
..style = PaintingStyle.stroke
..strokeWidth = 2.0
..isAntiAlias = true;
canvas.drawCircle(center, radius * 0.45, lastPaint);
}
}
棋子半径一般取cellSize的0.42倍。为什么不是0.5?因为两个相邻交叉点间距是cellSize,两个半径加起来如果等于cellSize,棋子就紧挨着,视觉上挤成一团。留一点间隙看起来更舒服,也方便玩家分辨落子位置。这个比例可以做成常量,方便后续调参。
4.3 棋盘缓存层优化
前面做了这么多绘制细节,如果每一帧都把背景、网格线、星位重画一遍,在RK3568上是浪费的。这些元素是静态不变的,真正会变的只有棋盘上的棋子列表。Flutter里有一个很好的优化思路:用PictureRecorder把静态棋盘层预渲染成Picture对象,每次paint时直接用canvas.drawPicture把整个棋盘贴上去。
代码思路是这样的:
dart复制ui.Picture? _boardPicture;
void _ensureBoardPicture(Size size) {
if (_boardPicture != null && _boardPictureSize == size) return;
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
_drawBackground(canvas, size);
_drawGridLines(canvas);
_drawStarPoints(canvas);
_boardPicture = recorder.endRecording();
_boardPictureSize = size;
}
尺寸变了就重新录制,尺寸没变就复用。paint主流程变成:
dart复制_ensureBoardPicture(size);
canvas.drawPicture(_boardPicture!);
for (final move in stones) {
_drawStone(canvas, geometry.offsetFor(move.row, move.col), _stoneRadius, move.color);
}
这里还要注意一点:recorded picture一旦生成,内部的Canvas操作已经固化,不能修改。所以棋盘层必须纯粹只画静态内容,棋子绝不能画进Picture里,否则棋子状态变化时没法更新。我当时第一版优化就踩了这个坑,把棋子也录进了Picture,结果落子后棋盘纹丝不动,排查了大半天才意识到Picture的不可变性。
这个优化在运行帧率上的提升很直观。静态层从几十次Canvas绘制调用压缩成一次drawPicture,OpenHarmony端Flutter引擎的光栅化压力小了很多。如果是做桌面端或者旗舰手机,可能感觉不明显,但在RK3568这种中低端开发板上,这就是能不能稳定跑到60帧的分水岭。
5. 手势与游戏逻辑
5.1 手势坐标反推落子位置
棋盘画出来了,接下来要能落子。手势处理的关键是把用户点击的像素坐标转换成棋盘的行列号,这一步我直接在BoardGeometry里实现了positionToIndex方法,原理前面已经写过:减去边距,除以cellSize,四舍五入到最近的整数行列。这个取整方向很重要,用round而不是floor或ceil,因为用户点到两个交叉点中间的位置时,应该自动吸附到距离最近的交叉点。
手势监听我用的是GestureDetector的onTapUp回调。有三个细节需要注意:第一,要用localPosition而不是globalPosition,因为CustomPaint在页面中的位置不代表全局原点,用全局坐标算出来的行列值是偏移的;第二,onTapUp的localPosition是相对于GestureDetector所在控件的,而CustomPaint的paint是相对于自身左上角,只要GestureDetector和CustomPaint是同一个控件,这两个坐标就是一致的,不要中间再套一层会改变坐标系的控件;第三,positionToIndex返回null时直接忽略点击,这种情况通常发生在玩家点到棋盘外围留白区域。
代码结构上是把棋盘控件包在一个GestureDetector里:
dart复制GestureDetector(
behavior: HitTestBehavior.opaque,
onTapUp: (details) {
final index = geometry.positionToIndex(details.localPosition);
if (index == null) return;
final row = index ~/ 15;
final col = index % 15;
onPlaceStone(row, col);
},
child: AspectRatio(
aspectRatio: 1.0,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CustomPaint(painter: ...),
),
),
)
behavior设成opaque是让空白区域也能命中手势,不然CustomPaint的透明区域点击事件可能被跳过。ClipRRect是为了让棋子在圆角边界处被裁剪,否则超出圆角的部分会露出来,影响观感。
5.2 落子校验、悔棋与胜利判定
落子逻辑核心是一个15×15的二维数组,初始全是null,黑白双方轮流把棋子颜色写入对应位置。数组外的数据结构还需要一个历史记录栈,用来支持悔棋。我做的是Move类,记录row、col、color三个字段,每次落子push进栈,悔棋时从栈顶弹出并清空对应位置。
dart复制class GameState {
final List<List<int?>> board;
final List<Move> history;
int currentPlayer = 0; // 0黑 1白
}
void placeStone(int row, int col, GameState state) {
if (state.board[row][col] != null) return;
state.board[row][col] = state.currentPlayer;
state.history.add(Move(row, col, state.currentPlayer));
if (checkWin(row, col, state.currentPlayer)) {
// 弹胜利对话框
} else {
state.currentPlayer = 1 - state.currentPlayer;
}
}
胜利判定只检查新落子的位置,不需要全盘扫描。因为只有最新落下的这颗棋子可能形成五连,所以从(r, c)出发,沿水平、垂直、主对角线、副对角线四个方向,分别往正反两个方向数连续同色棋子,总数达到5就判胜。核心循环:
dart复制bool checkWin(int row, int col, int player, List<List<int?>> board) {
const directions = [(0, 1), (1, 0), (1, 1), (1, -1)];
for (final (dr, dc) in directions) {
var count = 1;
for (final sign in [1, -1]) {
var nr = row + dr * sign;
var nc = col + dc * sign;
while (nr >= 0 && nr < 15 && nc >= 0 && nc < 15 && board[nr][nc] == player) {
count++;
nr += dr * sign;
nc += dc * sign;
}
}
if (count >= 5) return true;
}
return false;
}
这个实现的复杂度是O(1)级别的,因为每个方向最多走十几步就停止了。实际跑起来,RK3568上完全没有压力。悔棋操作要注意切换当前玩家,悔棋应该把当前玩家切回上一手,所以我用历史栈顶的颜色来判断:弹出last后,board[last.row][last.col]置空,currentPlayer = last.color,这样悔棋的语义才是完整的。
胜利后我处理方式是弹一个对话框,显示胜方颜色,并提供“再来一局”和“复盘”两个操作。“再来一局”直接清空数组和历史栈;“复盘”则是把历史栈完整保留,允许玩家逐步回放,这个功能后续还可以顺着历史栈做AI分析,属于后续扩展空间。
6. 常见问题与性能避坑
6.1 Flutter构建报错
OpenHarmony端Flutter构建报错,很多跟Android构建配置有关。最典型的一个:you are applying flutter's main gradle plugin imperatively using the apply script,这个报错通常发生在工程根目录的build.gradle里直接写了apply from或apply plugin指令来加载Flutter的Gradle插件,而新版Flutter/Gradle插件要求必须在settings.gradle里用pluginManagement + plugins块声明式加载。
解决方法是把根build.gradle里类似:
groovy复制apply plugin: 'dev.flutter.flutter-plugin-loader'
改成在settings.gradle里声明:
groovy复制pluginManagement {
plugins {
id 'dev.flutter.flutter-plugin-loader' version '1.0.0'
}
}
并确保apply false或者完全不apply,由子模块按需加载。这个坑在迁移老Flutter工程到新版本SDK时特别常见,OpenHarmony版Flutter SDK在不同版本间的Gradle插件要求也可能变化,出错了优先查官方迁移文档。
另一个常见问题是assets下载依赖失败,报错信息类似flutter assets will be downloaded from https://storage.flutter-io.cn...,这多半是镜像环境变量配置不正确或者网络波动。我的处理方案是把PUB_HOSTED_URL和FLUTTER_STORAGE_BASE_URL统一指向可用镜像,然后清掉flutter/bin/cache下的缓存,重新flutter pub get。OpenHarmony版SDK如果用自己的镜像仓库,也可能有单独的下载地址配置,按SDK文档来。
6.2 hdc连接与运行问题
OpenHarmony真机调试,hdc命令就是核心工具。我遇到最多的场景就是hdc list targets列不出设备。排查路径按优先级是:先检查USB线是不是数据线(很多充电线无法传输数据),再确认开发板设置里USB调试是否打开,然后看设备管理器里有没有识别出新的USB设备,最后重启hdc服务。hdc的服务重启命令是hdc kill,然后重新hdc start,跟adb的玩法几乎一样。
设备连接正常但安装失败,八成是签名问题。OpenHarmony对HAP的签名校验比Android严格,不匹配的签名直接拒绝安装。我当时的解决方式是在DevEco Studio里重新配置自动签名,如果还是不行,就找设备厂商要系统签名文件,配到build-profile.json5里。安装成功后如果应用启动闪退,先看hdc shell hilog查看系统日志,关键字是应用包名或Dart异常,大多数是so库没打包进HAP导致的,改ohos目录的配置就好。
还有一个细节:OpenHarmony工程编译过程中会生成大量中间文件,包括ohos目录下的build、.hvigor等,这些都可以安全删除,重新编译会自动生成。如果遇到编译环境诡异的问题,先清理再重来,往往能解决。
6.3 性能优化经验
五子棋棋盘绘制在自绘方案下其实不算复杂,但OpenHarmony设备的性能余量比高端Android手机小很多,所以我还是做了一些针对性优化,效果很明显。
第一,棋盘静态层用Picture缓存。这个前面4.3节已经详细说了,这是收益最大的一项。第二,静态层和动态层分离。我在做复杂一点的棋盘动画(比如落子时加一个缩放弹跳效果)时,把整个CustomPaint拆成两层:底层是用RepaintBoundary包着的静态棋盘层,上层是棋子层。这样棋子层动画只触发自己重绘,静态棋盘层完全不被影响。Flutter的RepaintBoundary在widget树里会阻断rerender向上传导,这是很实用的手段。
第三,注意paint方法里不要做任何耗时操作。比如不要在paint里打印日志、不要创建大量Paint对象、不要调用耗时的颜色转换。Paint对象尽量在painter构造函数里初始化好,paint方法只做canvas绘制。我在开发过程中曾经在paint里临时加debugPrint观察棋子坐标,结果帧率直接从60掉到20多,OpenHarmony上日志输出对性能的影响比Android更明显。
第四,合理使用isAntiAlias。棋子和网格线这些圆形或斜线元素开抗锯齿,纯横竖网格线其实可以关掉抗锯齿换取性能。实测下来,网格线关了抗锯齿,视觉上几乎看不出差别,但对大量线条绘制有一定加速作用。不过差异化处理增加了代码复杂度,如果棋盘层已经缓存成Picture,这个收益就不明显了,所以这是可选项。
最后再分享一个我个人摸索出来的小技巧:OpenHarmony设备上做自绘界面,尽量把绘制指令的次数控制在两位数以内。不是每一条线都算一次绘制,而是把同类型、同颜色的绘制操作合并成一次drawPath或drawPoints。Flutter的Canvas在OpenHarmony引擎里的调用开销比桌面端高不少,抽离合并之后帧率表现完全不一样。这个习惯我已经带到了其他模块的开发里,很值得推广。
