最近在折腾 Flutter for OpenHarmony 这套东西,把之前做的一个小游戏集合 App 往鸿蒙生态上搬。整个集合里已经跑了扫雷、数独和五子棋,轮到蜘蛛纸牌的时候,我本来以为最麻烦的是规则引擎,结果真正卡了我最久的,反而是看起来最简单的“牌面显示”这一层。标题里写的《Flutter for OpenHarmony游戏集合App实战之蜘蛛纸牌牌面显示》,就是这期要聊的内容。
牌面显示听着不起眼,但它其实是整个卡牌游戏的骨架:既要准确把牌的数据画出来,又要承载翻牌、拖拽这些交互反馈,还得在 OpenHarmony 这种非主流 Flutter 平台上保持流畅。如果你也在做 Flutter 游戏、或者正在把现有 Flutter 工程适配到 OpenHarmony,这篇文章里的布局方案、状态管理思路、以及那些渲染器踩坑记录,应该能帮你少走不少弯路。
1. 整体设计:牌面显示到底在解决什么问题
1.1 为什么牌面显示值得单独拆出来做
很多人写卡牌游戏,喜欢把“牌面显示”和“游戏逻辑”混在一起,觉得不就是把一张图片或者一段文字摆上去吗?实际做下来完全不是这么回事。牌面显示至少同时承担了三层职责:
第一层是数据渲染。蜘蛛纸牌用两副牌,一共 104 张,每张牌有花色、点数和正反面状态。你需要在屏幕上把这些信息准确、清晰地画出来,而且画出来的样子要让玩家一眼就认出“这是黑桃 9 还是红桃 9”。
第二层是交互反馈。玩家点一张牌要能选中它,拖动时要能看到牌组跟着手指走,移动到合法位置时要有高亮提示。这一层如果做得糙,玩家就会觉得游戏“很生硬”。
第三层是动画载体。发牌动画、翻牌动画、收牌动画,全都是在牌面这个 Widget 上做文章。没有一套合理的牌面结构,后面每一个动画都会变成灾难。
我当时的做法是先把“牌面”抽象成一个独立的 UI 层,数据层通过接口喂给它,交互层通过回调通知逻辑层。这样牌面显示这部分只管“怎么画”“怎么动”,不用关心“能不能这么移”这种规则问题。这样做的好处是,后续如果要换主题皮肤、加动画特效,都不需要动规则引擎。
1.2 技术选型:为什么用代码绘制而不是贴图
做扑克牌牌面,摆在我面前的有三条路:用图片资源、用代码绘制、或者两者混合。我在项目里最终选择了“代码绘制为主、字体符号辅助”的方案,原因很实在。
图片方案最直观,找一套漂亮的高清扑克牌素材,每个花色点数一张图,再加一张牌背图。但蜘蛛纸牌摸牌时,每张牌要单独显示,104 张牌就要 53 张图(52 张正面加 1 张背面),打包体积会变大。而且图片资源有分辨率问题,屏幕缩放后容易发虚,后期想换一套牌面风格还得重新做一套图。
代码绘制的核心思路是用 Flutter 原生组件搭牌面:白色圆角矩形是牌底,左上角放点数和花色,中间放大花色。花色符号用 Unicode 字符(黑桃 ♠、红桃 ♥、方块 ♦、梅花 ♣)来画,点和数字用 Text 来画。这样做的好处是资源体积几乎为零,任意分辨率下都是矢量渲染,换主题只需要改颜色和字体。
代价就是字体兼容性需要额外处理。后面我会详细说在 OpenHarmony 上字体符号踩的坑。
1.3 布局方案:Stack 加 Positioned,而不是 CustomMultiChildLayout
牌桌布局有三种常见选择:Stack 加 Positioned、CustomMultiChildLayout、以及直接用 Column 嵌套。
我试了一圈,最后用的是 Stack 加 Positioned。原因很简单:蜘蛛纸牌的牌桌结构其实是个静态棋盘。左上角是发牌堆(Stock),右上角是收牌区(Foundation),中间下方是 10 个牌列(Tableau)。每个牌列里的牌需要按照一定偏移堆叠,这不是常规的线性布局能搞定的,Stack 加 Positioned 刚好能描述这种“自由定位”的需求。
CustomMultiChildLayout 也能做,它的优势是可以通过 delegate 统一计算每个子组件的位置,但代价是代码复杂度明显上升,而且每次布局变化都要重跑 delegate。对于 104 张牌的规模,Stack 加 Positioned 在性能上完全够用,代码还更容易读。后面在性能调优章节我会提一下 RepaintBoundary 怎么配合 Stack 使用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 牌面数据模型与状态管理
2.1 扑克牌的数据结构与为什么不用单副牌模型
牌面显示的开始,不是 Widget,而是数据结构。一个干净的数据模型,能让后面所有 UI 代码都变得顺手。
我在项目里定义了一个 CardModel 类,核心字段如下:
dart复制enum CardSuit { spade, heart, diamond, club }
class CardModel {
final CardSuit suit;
final int rank; // 1=A, 2-10, 11=J, 12=Q, 13=K
bool faceUp;
bool isDragging;
CardModel({
required this.suit,
required this.rank,
this.faceUp = false,
this.isDragging = false,
});
bool get isRed => suit == CardSuit.heart || suit == CardSuit.diamond;
String get rankLabel {
switch (rank) {
case 1: return 'A';
case 11: return 'J';
case 12: return 'Q';
case 13: return 'K';
default: return '$rank';
}
}
String get suitSymbol {
switch (suit) {
case CardSuit.spade: return '♠';
case CardSuit.heart: return '♥';
case CardSuit.diamond: return '♦';
case CardSuit.club: return '♣';
}
}
}
这里有个容易踩的坑:不要只存花色和点数,不加唯一 id。蜘蛛纸牌用的是两副牌,也就是说场上会出现两张完全一样的“黑桃 A”。如果在拖拽或者比较牌的时候用 suit + rank 做身份标识,就会出现两张牌互相混淆的情况。我一开始没注意,结果做撤销功能的时候,经常撤销错牌。后来老老实实给每张牌加了唯一 id,这种问题才消失。
2.2 状态更新的粒度:别用全局 setState 重建整个牌桌
104 张牌,十几个区域,如果每次翻一张牌就 setState 整个牌桌,性能会很差,而且会出现莫名的闪烁。我的做法是把状态按区域拆分:每个牌列(Tableau)用一个独立的 ChangeNotifier 管理,收牌区、发牌堆也各自独立。
具体来说,牌桌状态类大致长这样:
dart复制class TableauColumn extends ChangeNotifier {
final List<CardModel> cards = [];
// 翻牌、加牌、移牌、清空等操作...
// 每次操作后 notifyListeners();
}
UI 层用 ListenableBuilder 监听对应的列,这样某列翻牌时,只有那一列会重建,其他列完全不受影响。蜘蛛纸牌里一列最多也就十来张牌,局部重建的开销非常小,操作起来很跟手。
这种“按列监听”的思路,和前端里“按组件拆分状态”是一个道理。不要嫌麻烦,这一步做好了,后面加动画、加撤销都会轻松很多。
2.3 faceUp 状态机:不要直接用布尔值驱动翻牌动画
牌面是否显示正面,最直观的办法就是 CardModel.faceUp 的布尔值。但如果你直接拿这个值去切换正反面,翻牌动画就没法做了——动画需要一个从“旧状态”到“新状态”的过渡过程。
我在项目里给每张牌加了一个轻量级状态机:hidden -> flipping -> shown。hidden 表示牌背朝上,shown 表示正面朝上,flipping 是中间状态,动画控制器在这段时间内跑一个 0 到 1 的插值。动画结束后再把状态切到 shown。
这个设计让我后来加“开局逐张翻牌”动画时几乎没改什么代码,只是把每张牌的翻牌动画时间错开而已。如果你一开始就把 faceUp 当成一个简单布尔值来用,后面回头补动画会非常痛苦。
3. 牌面组件与牌桌布局实现
3.1 CardWidget 基础结构:白色圆角底加信息层
牌面的基础组件我命名为 CardWidget,它接收一个 CardModel 和宽度,内部按 0.7 的宽高比来定高度。这个宽高比是扑克牌的标准比例,也是经过我实测后最顺眼的。
基础结构大概是这样的:
dart复制class CardWidget extends StatelessWidget {
final CardModel card;
final double width;
const CardWidget({super.key, required this.card, required this.width});
@override
Widget build(BuildContext context) {
final height = width * 0.7;
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(width * 0.08),
border: Border.all(color: const Color(0xFFB0B0B0), width: 1),
boxShadow: const [
BoxShadow(
color: Color(0x33000000),
blurRadius: 3,
offset: Offset(0, 1),
),
],
),
child: card.faceUp
? _CardFace(card: card, width: width)
: _CardBack(width: width),
);
}
}
_CardFace 负责绘制点数信息:左上角放 rankLabel 和 suitSymbol,中间放一个大号 suitSymbol。注意这里要处理“10”这个点数,它是两个字符,如果左上角用 TextAlign.left 加 Padding,宽度不够时容易显示不全。我的处理是左上角区域给一个最小宽度,用 FittedBox 做自动缩放。
_CardBack 则是一张纯色底加斜纹图案,为了简单我用一个 CustomPaint 画了几条斜线,视觉上有牌背的感觉就够了。牌背不需要太精细,重点在于正面信息要清楚。
3.2 花色符号的字体兼容坑
这里必须单拎出来说,因为这是个能让你折腾一晚上的问题。
Unicode 字符里确实有 ♠ ♥ ♦ ♣,但它们在系统字体里的渲染效果差别很大。有的字体里这四个符号大小不一,有的字体里高度对不齐,最坑的是 OpenHarmony 设备上某些默认字体根本不含这些字形,显示出来是方框。
我在项目里踩过的坑记录如下:
第一次直接 Text('♠'),在普通 Android 设备上没问题,跑到 OpenHarmony 模拟器上变成方框。第二次换了一款开源符号字体,整体效果不错,但字体文件大概有两三 MB,为了四个符号引入这个体积有点亏。
最终的方案是:正常系统中优先用系统字体渲染,同时设置 fontFamilyFallback,把打包的符号字体作为兜底。Flutter 的 TextStyle 支持 fontFamilyFallback 参数,这个参数在字体缺字形时会逐个尝试后面的字体:
dart复制Text(
card.suitSymbol,
style: TextStyle(
fontSize: 28,
color: card.isRed ? Colors.red : Colors.black,
fontFamilyFallback: ['NotoSansSymbols'],
),
)
这样体积和兼容性都兼顾了。如果你不想引入额外的字体文件,还有一个方案是用 CustomPaint 自己画四个花色图形,这个完全可控,但工作量会稍大一点,而且画出来的图形风格可能和字体不统一。我建议先用字体方案,实在不行再自绘。
3.3 牌堆堆叠偏移算法:正面牌与背面牌要分开算
蜘蛛纸牌的布牌,最核心的算法就是“一列里每张牌应该偏移多少”。这里有个基本矛盾:正面朝上的牌,玩家希望看到每一张的信息,偏移量要大一些;但牌列长了以后,如果偏移量太大,整列会超出屏幕。
我的经验是把偏移量分成两种:faceUpSpacing 和 faceDownSpacing。背面朝上的牌只需要露出顶部一小条,让玩家知道下面还有牌,偏移量通常是卡片高度的 12% 左右;正面朝上的牌偏移量要大,通常设为卡片高度的 22% 左右,这样能看清数字和花色。
但如果某一列的正面牌太多,比如叠了八张正面牌,22% 高度偏移会把列拉得很长。这时要做压缩处理。我写了一个简单的逻辑:
dart复制double calculateSpacing(int visibleCount, double cardHeight, double maxStackHeight) {
final maxSpacing = cardHeight * 0.22;
final minSpacing = cardHeight * 0.10;
final available = maxStackHeight - cardHeight;
if (visibleCount <= 1) return 0;
final ideal = maxSpacing;
final maxAllowed = available / (visibleCount - 1);
return max(minSpacing, min(ideal, maxAllowed));
}
这段逻辑的意思是:先是理想偏移量,如果放不下就逐步压缩,但最小不能低于牌高的 10%,否则牌面会被盖得完全看不清。这个“10% 下限”是我拿模拟器反复试出来的,低于这个值,最底下那张牌的左上角数字就会被遮住一截,看着很难受。
顺带一提,牌列的 Positioned 布局里,后面的牌要盖住前面的牌,所以 Stack 中牌的顺序和牌在列中的顺序要一致,被压在最下面的先加入 Stack。很多新手写反了,导致后面的牌被前面的牌遮住,交互时选不中想要的牌。
3.4 牌桌的三种区域布局
整个牌桌在 Stack 里分成三块:
顶部左侧是发牌堆(Stock),显示剩余牌堆的牌背图标和剩余张数。顶部右侧是收牌区(Foundation),8 个空位排成一行,用于接收完成的 K 到 A 同花色序列。
中下部是 10 个牌列(Tableau),这是占屏幕空间最大的区域。因为我用的是 Stack 加 Positioned,10 个列的位置需要计算好。我的做法是:先根据屏幕宽度算出每张牌的宽度,然后按 10 列等分的方式,给每列留下一个矩形区域,列内的牌再根据 3.3 的偏移算法上下堆叠。
这里有个交互细节:每列的“可点击区域”要比牌本身稍大一点。因为空列时玩家要能点击空位把牌移进去,如果点触区域只限制在牌的大小内,空列就很难点。我给每个列区域都套了一个带 behavior: HitTestBehavior.opaque 的容器,保证整块区域都能响应手势。
4. 牌面交互联动:翻牌、拖拽与高亮提示
4.1 翻牌动画:三维翻转的替代实现
翻牌动画最直观的做法是做三维翻转:牌绕着中轴从 0 度转到 180 度,前半段显示正面,后半段显示背面。在 Flutter 里可以用 Transform 加 Matrix4 来实现。
我的实现思路是用一个 AnimationController 控制 0 到 1 的值,然后根据角度把 X 轴缩放压在 1 到 0 再到 1 的区间:
dart复制AnimatedBuilder(
animation: _controller,
builder: (context, child) {
final angle = _controller.value * pi;
final scaleX = cos(angle).abs();
return Transform(
alignment: Alignment.center,
transform: Matrix4.diagonal3Values(scaleX, 1.0, 1.0),
child: angle < pi / 2 ? _frontWidget : _backWidget,
);
},
)
注意,这里的 _controller.value 从 0 到 1,代表翻完整个动作。当角度小于 90 度时显示正面,大于 90 度时显示背面,这样就能做出“翻过去”的效果。
在 OpenHarmony 上跑这个动画,第一个版本我遇到了一个问题:Transform 的矩阵变换在某些 GPU 驱动下会闪烁。后来排查发现是渲染器抗锯齿的问题,切到 Skia 渲染器就好了。这个我在下一章展开。
另外有一点要提醒:如果你用 AnimatedSwitcher 做正反面切换,那是淡入淡出效果,不是翻牌效果。这两个概念一定要分清,前者只是一个淡变,后者才符合真实扑克牌的物理感觉。
4.2 拖拽时牌面跟随:用什么方式移动最稳
蜘蛛纸牌的拖拽,需要把一叠牌跟着手指移动。我试过两种方案,差别很大。
第一种,用 AnimatedPositioned 去更新被拖牌组的位置。这种做法在松开手指后会有一段过渡动画,看起来很顺滑,但拖拽过程中每次手指移动都会触发 Positioned 的 layout 变化,如果这一叠牌有五六张,每张都要重新布局,帧率会明显下降,在模拟器上尤其明显。
第二种,拖拽过程中用 Transform.translate 做视觉偏移,不改变布局位置。手势结束时,再根据落点决定调用 AnimatedPositioned 还是直接重置。这样做的好处是,拖拽过程中 Flutter 不需要重新布局,只需要重绘,性能开销小一个量级。
我的做法是:当手势开始时,把被拖牌组“提取”出来,放到 Stack 的最顶层,用一个 ValueNotifier<Offset> 记录手指位置,通过 Transform.translate 实时更新:
dart复制ValueListenableBuilder<Offset>(
valueListenable: _dragOffset,
builder: (context, offset, child) {
return Transform.translate(
offset: offset,
child: child,
);
},
child: draggedGroupWidget,
)
这个方案的另一个好处是,被拖牌组的 Z 轴顺序稳定在最上层,不会被其他牌列盖住。如果你只是简单地把牌组放在原位置,拖到别的列上方时,就会被那一列的牌遮挡,视觉上像是“钻到下面去了”,很别扭。
4.3 合法落位的高亮提示
合法落位提示是牌面显示的一部分,不只是规则问题。当玩家拖着一组牌到某列上方时,如果这个位置允许放下,就在该列顶部画一个高亮边框;如果不行,就不显示任何提示。
高亮我用的是一个简单的 Container,在列区域内画 1.5 像素的圆角边框,颜色用半透明的蓝色。原理很简单,但有个细节要注意:高亮提示要在整个牌组的上方,否则会被牌组挡住看不见。所以高亮容器也要放在 Stack 的顶层,并且只在拖拽进行中显示。
蜘蛛纸牌的移动规则比普通纸牌游戏严格:只能移动同花色且点数连续的降序序列,目标列最上面一张牌的点数要比待移动牌组的首张牌大 1。为了配合高亮提示,我在 UI 层写了一个轻量的合法性预检,只判断“能不能接”,完整的规则校验仍然走游戏逻辑层。这样拖拽过程中高亮响应足够快,又不会把规则逻辑散落在 UI 代码里。
5. OpenHarmony 上跑 Flutter 的适配实战
5.1 工程与 SDK 版本对齐
Flutter for OpenHarmony 现在的主流做法是使用 OpenHarmony SIG 维护的 flutter_flutter、flutter_engine 仓库,配合 ohos 目录在 DevEco Studio 中构建 HAP。这个流程本身不难,难的是版本对齐。
我遇到的最典型的报错是这句:The current configured Flutter SDK is not known to be fully supported. Please ...。这个报错的意思是,你当前配置的 Flutter SDK 版本和当前工程的预期版本不匹配。第一次遇到时我以为是环境坏了,反复 reinstall,后来才发现是 local.properties 里 flutter.sdk 指向的路径不对,以及 Flutter 版本和 fluter_ohos 分支版本没对齐。
解决办法是:先确认工程使用的 flutter 版本,再切换对应的 OpenHarmony 分支。这类 fork 仓库一般会在 README 里标明配套的 Flutter 版本,照着对齐就行。不要本地装一个最新版就往上冲,官方最新版并不一定支持 ohos 平台。
5.2 Impeller 与 Skia 渲染器的实际表现
Impeller 是 Flutter 新一代渲染器,目标是解决 Skia 在部分场景下的 shader 编译卡顿问题。但在 OpenHarmony 上,Impeller 的适配还在推进中,实际表现并不稳定。
我项目里的牌面 UI 涉及大量圆角矩形、阴影和透明叠加。在 Impeller 渲染器下,部分模拟器和真机上出现了牌面边缘锯齿、圆角不均的问题,尤其在翻牌动画的 90 度临界点附近,能看到明显的闪烁。而切回 Skia 之后,这些问题立刻消失。
我的建议是:现阶段在 OpenHarmony 上以 Skia 为主渲染器,跑通功能后再尝试 Impeller,如果遇到视觉异常,优先回退。不要为了追新渲染器在开发阶段浪费时间,先把核心功能稳住。配置切换方式在不同版本里略有差异,通常是在运行参数里加上禁用 Impeller 的开关,或者通过引擎初始化参数设置,具体以你用的 fork 仓库文档为准。
5.3 触摸事件与系统手势冲突
OpenHarmony 的窗口触摸体系在移植 Flutter 时,GestureDetector 绝大多数情况下能正常工作。真正烦人的是系统边缘手势。
在平板上玩蜘蛛纸牌,如果牌拖到了屏幕边缘附近,系统的侧边返回手势可能会抢占触摸事件,导致拖拽中断。这个问题在手机上也存在。我做了两件事来缓解:一是给牌桌区域的侧边留出一段安全边距,避免牌完全贴边;二是在游戏界面拦截返回手势,通过 PopScope 控制返回逻辑,让误触返回时弹的是“确认退出”而不是直接退游戏。
还有个小细节:OpenHarmony 模拟器的触摸事件有时会带一点延迟,拖拽不那么跟手。设置里把模拟器性能模式调高后会好很多。真机上实测基本没问题。
5.4 与 ArkUI 混编的边界
做游戏集合 App,不可能所有页面都用 Flutter 写。像设置页、用户协议、版本更新这些系统属性很强的页面,用 ArkUI 写反而更省事。这里不需要把整个 App 都押在 Flutter 上,我们只把游戏主体用 Flutter 页面承载,其他页面继续走鸿蒙原生。
Flutter 页面嵌入鸿蒙工程,我在项目里用的是 FlutterViewController 类似的方式,在 ArkUI 页面里通过 ComponentContent 添加 Flutter 容器。Flutter 和原生之间的通信,用 MethodChannel 做简单调用,比如回传游戏分数、读取系统设置。这套混编虽然有一些初始化时的版本约束,但只要通道设计得干净,两端不会互相干扰。
我的经验是:混编时,职责边界要在第一天就划清楚。Flutter 侧只做游戏渲染与交互,原生侧管生命周期、系统能力和外部跳转。不要把 Flutter 侧当成万能层,什么功能都塞进去,后期调试会非常痛苦。
5.5 牌面自适应与屏幕安全区
游戏集合 App 要在手机、平板、甚至折叠屏上跑,牌面大小不能写死。我的计算逻辑很简单:取屏幕短边的比例来定牌宽。
dart复制final screenSize = MediaQuery.of(context).size;
final cardWidth = min(screenSize.width / 10, screenSize.height / 14);
取宽高分别除以一个基准值,再取较小值,这样能保证 10 列牌桌在横屏或竖屏下都不会挤爆。这里要注意,OpenHarmony 平板的屏幕宽高比差异较大,在 MediaQuery 拿到的尺寸是逻辑像素,不是物理像素,需要确认你使用的 Flutter 版本对 density 的处理方式是否一致,否则会出现“看得到牌但点不准”的诡异问题。我在一个早期版本里就遇到过坐标偏移,排查到最后是 density 换算不一致导致的,排除了半天。
6. 常见问题与排查技巧实录
6.1 牌面文字发虚或花色不显示
这个在前面已经提过,这里把排查思路再系统地列一遍。代码写法正确的情况下,优先检查字体资源是否真的被打包进去了。Flutter 的 pubspec.yaml 里声明字体后,需要执行 flutter pub get,并且在构建时确认字体被编入产物。我遇到过一次改了 pubspec.yaml 忘记 pub get,结果字体一直没生效,排查了好久。
如果字体确已打包但依然发虚,大概率是渲染器的问题。OpenHarmony 上部分渲染引擎对文字抗锯齿的处理有差异,可以切换 Skia 渲染器对比一下。另外,设置文字颜色时注意高对比度,红桃和方块用深红,别用亮红,亮红在白底上边缘会显得发虚。
6.2 开局发牌动画掉帧
开局时要把 104 张牌逐一发到 10 列,如果直接用循环 setState,动画会卡成 PPT。我的优化思路是:先发第一批可见的牌,其他牌以牌背堆叠形式预先占位,再通过逐张延迟动画翻牌。这样 UI 线程同一时间只处理少数几张牌的动画,压力小很多。
具体做法是给每张牌设置一个延迟启动的动画控制器,用 Interval 把动画曲线切到对应时间窗口。这个方案实测下来,低端设备上也能保持流畅。
6.3 拖拽时牌面闪烁或重影
拖拽过程中出现闪烁,最常见的原因是整棵 Widget 树在频繁重建。被拖牌组每移动一个像素,如果触发的是整个 Stack 的 setState,所有牌都会跟着重建,就会出现残影。
解决方法是给被拖牌组和牌桌各加一个 RepaintBoundary。RepaintBoundary 会把子树隔离成独立的绘制层,子树内部变化时不会触发兄弟节点重绘。加了这个之后,拖拽时只有被拖牌组的重绘区域在变化,闪烁问题基本消失。
6.4 落位坐标偏差
拖拽松手后,牌没有落到预想的位置,往往是坐标转换的问题。OpenHarmony 的窗口在多屏或带有系统栏的时候,Flutter 拿到的手势坐标和布局坐标可能不是同一套基准。我的处理是统一用布局坐标,手势事件进来后先做一次全局坐标到局部坐标的换算,再参与落位计算。不要在多个地方各算各的,很容易偏差。
6.5 问题速查表
| 问题现象 | 可能原因 | 处理方法 |
|---|---|---|
| 花色符号显示为方框 | 系统字体缺少字形 | 配置 fontFamilyFallback 或打包符号字体 |
| 牌面圆角锯齿 | Impeller 渲染器兼容问题 | 切换 Skia 渲染器 |
| 拖拽时闪烁 | 整棵 Widget 树重建 | 使用 RepaintBoundary 隔离重绘区域 |
| 开局发牌卡顿 | 一次性 setState 所有牌 | 分层刷新,逐张延迟动画 |
| 落位位置不准 | 坐标基准不一致 | 统一使用布局坐标计算落点 |
| SDK 版本报错 | Flutter 与 ohos 分支版本不匹配 | 对齐 fork 仓库要求的版本 |
| 返回手势打断拖拽 | 系统边缘手势抢占 | 拦截返回手势,预留安全边距 |
7. 实战心得与扩展方向
写到这里,牌面显示的部分差不多讲完了。最后分享一点我自己的体会。
做蜘蛛纸牌这个项目之前,我总以为“显示牌面”是整件事里最没技术含量的一环,实际投入开发后才发现,牌面显示是连接数据、交互和动画的枢纽。数据结构设计得干净,牌面组件写起来就顺手;牌面组件性能优化到位,拖拽和动画才能保持流畅;而这一切到了 OpenHarmony 上,又要额外考虑渲染器、字体和窗口行为这些平台差异。真正把这一层吃透,其他卡牌游戏做起来会轻松很多。
这个项目后续我打算扩展的方向,一是给牌面加更细腻的动画,比如收牌时的飞牌动效和完成整组时的聚光特效,这些都可以在现有牌面结构上加层实现;二是做皮肤系统,让玩家可以切换不同的牌面风格,代码绘制方案的优势这时候就体现出来了;三是把状态管理再往下沉一层,配合撤销和存档功能,保证任何操作序列都能完整回放。蜘蛛纸牌的规则只算是基础,牌面显示做到位了,游戏体验的上限才能拉高。
如果你正准备用 Flutter 在 OpenHarmony 上做类似的卡牌游戏,建议先把牌面显示这层扎扎实实搭好。别急着写规则引擎,先把一张牌从数据到 UI 的链路跑通,再扩展到十列牌桌,然后逐步加交互和动画。这样每一步都有清晰的验证节点,遇到问题也能快速定位。
