☰
HarmonyOS Canvas镂空效果实战:从刮刮乐到文字挖空
2026/10/11 21:56:52 网站建设 项目流程

做应用开发的人应该都有这种需求:界面上某一块区域要"消失",露出底下的内容。比如刮刮乐、卡片解锁、异形遮罩、文字挖空,本质上都是在图层上打洞。最近在 HarmonyOS 6 的开发环境里做营销页,产品要求封面图上有一行镂空文字,让背景视频从笔画中间透出来。我第一反应是找设计师出两张 PNG 叠加,但视频背景一动,遮罩就对不上位了。后来回到 Canvas 方案,用 Canvas 实现镂空效果,才真正把这块的逻辑理顺。这篇文章就是把那几天的探索过程、代码复盘、还有踩过的坑按实战顺序整理出来,给要做类似效果的开发者一条能直接落地的路径。

如果你是刚接触 HarmonyOS 的 ArkTS 开发,或者已经写过一点 Canvas 但没碰过合成模式,这篇文章都适用。我不打算泛泛讲 API 文档,而是先带你想清楚"镂空"在像素层面到底是什么,再给出两个可以抄走的 Demo,最后聊几个必须注意的细节。这样你拿到代码后不只会跑,还能根据需求自己改。

1. 先分清"裁切遮罩"和"像素挖空",别一开始就走错路

1.1 为什么普通 View 堆叠解决不了动态镂空

很多需求用图片遮罩也能应付。比如固定位置放一个带圆孔的 PNG,下面叠一张图片,视觉上就是一个圆洞。问题在于这种方式是"静态遮挡",孔的位置、大小、形状全部写死在图片里。营销活动里的镂空往往要跟着用户手势变,或者跟着数据实时变,比如刮奖、签名、解锁图案,这种场景下准备几十张图片也不现实。

另一个隐藏问题是层级同步。当底下的内容不是静态图片而是一段视频,或者是一个滚动列表,图片遮罩必须时刻对齐动态内容的坐标系。一旦页面发生滚动、缩放、旋转,遮罩层和内容层就容易出现肉眼可见的错位。Canvas 方案不一样,它直接把镂空的结果绘制在当前这一层像素上,底层内容由系统合成,天然对齐。

1.2 Canvas 合成模式是更贴近像素的方案

HarmonyOS 的 Canvas 组件封装了 2D 绘制能力,所有绘制命令最终都会落到一个像素缓冲区上。如果只是普通绘制,后画的图形会覆盖先画的图形,这是默认行为。但 Canvas 上下文里有一个叫globalCompositeOperation的属性,它允许你改变"新像素"和"已有像素"之间的混合规则。

镂空效果主要用到的规则是destination-out。它的直观理解是:把已有画面当成一张照片,新绘制的图形当成一块橡皮擦,橡皮擦经过的地方,照片像素变透明。这正是"像素挖空",比遮罩更进一步:遮罩只能固定挡在某个区域,像素挖空可以在运行时随意改变形状和位置,而且挖掉后的透明区域会参与系统的统一合成,下层内容自动透出来。

我在刚开始做的时候,其实还纠结过一个问题:能不能直接用ClipPath裁切?ClipPath也能限制显示区域,但它更像"只看被保留下来的部分",而不是"把中间挖掉露出来"。做镂空文字、刮擦效果时,destination-out更符合直觉,因为我们需要的结果是:某块区域的像素透明度变成 0,其余像素保持不变。

2. 理解合成模式的工作原理,比背 API 重要

2.1 合成模式里的几个关键角色

globalCompositeOperation在 Canvas 2D 里有十几种取值,但做镂空效果真正需要关注的其实只有两类:默认的source-over和用于擦除的destination-out。用一张表格来看它们的行为:

合成模式产生结果典型用途
source-over新绘制内容覆盖在原有内容上方普通描边、填充、绘制图片
destination-out已有内容在新绘制内容的区域中被擦除变透明镂空、刮奖、橡皮擦
source-atop新内容只绘制在已有内容的非透明区域中给已有图形添加纹理
destination-over新内容绘制在已有内容下方背景合成

destination-out特别适合做"挖空"还有一个原因:它和主流图形 API 里的橡皮擦语义一致。你在source-over模式下画了一条线,然后用destination-out模式下画同样的线,这条线的位置就会从画布上消失。如果反复描同一个位置,不会越擦越深,因为 alpha 已经变成 0 了,继续擦除没有变化。

2.2 destination-out 的像素透明度公式

理解这个模式对调试很有帮助。它的像素计算可以简化成一句话:最终透明度 = 原有透明度 × (1 - 源透明度)。这里的"源"就是你当前正在绘制的内容,它的形状决定了要擦除的区域,它的透明度决定了擦除强度。

我举个例子。如果画布上有一个不透明的红色矩形,然后我用fillStyle = 'rgba(0,0,0,1)'画一个圆,圆的区域会被完全擦除,透明度变成 0。如果我用fillStyle = 'rgba(0,0,0,0.5)'画同一个圆,圆的区域只会变成半透明,原透明度乘以 0.5,最终是 0.5 的 alpha。很多开发者第一次做镂空时发现效果"擦不干净",就是因为源颜色带了半透明,或者抗锯齿边缘影响了 alpha。

所以在做全透效果时,一定要保证源绘制的透明度是 1。颜色本身是什么不重要,因为destination-out只关心形状和透明度,不关心色相。即使你把fillStyle设置成红色、绿色,擦除结果都一样,只要 alpha 是 1。我通常统一写成最醒目的rgba(0,0,0,1),这样团队成员看到代码时一眼就能知道这是用于擦除的形状。

2.3 抗锯齿与半透明边缘带来的视觉差异

Canvas 的抗锯齿是默认开启的。在destination-out模式下画一个圆,圆的边缘像素不会从 1 突然变成 0,而是经过一层渐变过渡。这其实是个好消息,因为大多数 UI 效果都需要平滑边缘。但如果你做像素风游戏,或者需要一格一格精确擦除的效果,抗锯齿会让边缘产生一圈"半透明残影",看起来就像擦得不干净。

如果确实需要锯齿分明的边缘,可以在创建CanvasRenderingContext2D时把抗锯齿关掉,也就是RenderingContextSettings的参数改成 false。不过我建议只在特定小场景下关闭,全局关闭会让普通文字和图片变得非常毛糙。做营销页这种视觉向产品时,保留抗锯齿明显更符合审美。

3. 实战一:做一张可交互刮刮乐卡片

3.1 页面结构:底层奖品信息 + 上层刮刮乐 Canvas

第一个实战案例是刮刮乐。页面的结构很简单:底层放一个 Text 组件展示奖品文案,上层放一个同样大小的 Canvas 组件。Canvas 负责绘制银灰色的覆盖层,手指滑动时通过destination-out把路径擦掉,底层文案就能逐笔露出来。

在 HarmonyOS 的 ArkTS 里,这个层级关系可以用Stack容器实现。这里有一个容易被忽略的点:上下两个组件的尺寸必须保持一致,否则手指在 Canvas 上滑动时,坐标和视觉位置会偏差。实际开发时不要手动写死宽高,最好用.width('100%').height(...)或相对布局来保证两个层级完全对齐。

3.2 初始化画布,绘制覆盖涂层

在 Canvas 组件的onReady回调里,这个阶段的画布已经准备好,可以立即绘制。我们要做的第一步是:用不透明的灰色填充整个画布区域,让用户看到一层"待刮"的涂层。代码如下:

@Entry @Component struct ScratchCardDemo { private settings: RenderingContextSettings = new RenderingContextSettings(true) private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings) private lastX: number = 0 private lastY: number = 0 build() { Stack() { // 底层奖品信息 Text('恭喜获得 100 积分') .fontSize(24) .fontColor('#333333') .width(300) .height(200) .textAlign(TextAlign.Center) // 上层 Canvas 刮层 Canvas(this.ctx) .width(300) .height(200) .onReady(() => { this.ctx.fillStyle = '#c0c0c0' this.ctx.fillRect(0, 0, 300, 200) }) .onTouch((event: TouchEvent) => { this.handleScratch(event) }) } } }

这里的RenderingContextSettings(true)表示开启抗锯齿。Canvas 的尺寸我用了 300 × 200,实际项目里应该用vp单位并且和底层容器尺寸保持一致。

3.3 用 destination-out 跟随手指擦除涂层

核心交互逻辑在触摸回调中。我们需要区分Down和Move两种触摸类型。Down是手指刚按下的瞬间,需要把起点记录下来,并且画一个很小的小点;Move是手指移动的过程,应该从上一位置连线到当前位置,形成连续的擦除轨迹。

private handleScratch(event: TouchEvent) { if (event.touches.length === 0) { return } const touch = event.touches[0] const x = touch.x const y = touch.y const ctx = this.ctx ctx.globalCompositeOperation = 'destination-out' ctx.lineWidth = 32 ctx.lineCap = 'round' ctx.lineJoin = 'round' ctx.strokeStyle = 'rgba(0, 0, 0, 1)' ctx.beginPath() if (event.type === TouchType.Down) { this.lastX = x this.lastY = y // 只点不划也要有一个像素级的小线段,否则按下不会产生任何擦除 ctx.moveTo(x, y) ctx.lineTo(x + 0.1, y + 0.1) ctx.stroke() } else if (event.type === TouchType.Move) { ctx.moveTo(this.lastX, this.lastY) ctx.lineTo(x, y) ctx.stroke() this.lastX = x this.lastY = y } // 用完就恢复,避免影响同一上下文里的后续绘制 ctx.globalCompositeOperation = 'source-over' }

我特别想提醒两个细节。第一,lineWidth用的是 32,这个值决定了刮痕宽度,你们可以根据视觉效果调整。第二,strokeStyle的颜色虽然写成黑色,但在这里颜色值不会影响最终界面,真正参与计算的是透明度。把 alpha 写成 1,才能保证擦除后的区域完全透明。

还有一个我最初踩过的坑:只在Move里画线,没有处理Down瞬间的点,用户快速点击一下屏幕,涂层完全不动,体验很怪。后来在Down分支里补了一条长度为 0.1 的极短线,完美解决。因为 Canvas 的moveTo不产生绘制结果,必须要有一个实际的线段才触发stroke。

3.4 扩展:统计刮开比例,判断是否揭晓

刮刮乐很多时候需要在刮开一定比例后自动展开结果。这个需求的实现思路是:读取 Canvas 像素数据,统计 alpha 为 0 的像素比例。ArkTS 里可以通过ctx.getImageData拿到像素缓冲区,但要注意getImageData返回的是一大块 RGBA 数组,遍历时按每四个分量一组处理。为了减少性能压力,可以每隔几个像素采样一次,而不是全部遍历。

下面是抽样的伪代码思路:

const imageData = this.ctx.getImageData(0, 0, 300, 200) const data = imageData.data let transparentCount = 0 let totalCount = 0 for (let i = 3; i < data.length; i += 16) { totalCount++ if (data[i] === 0) { transparentCount++ } } const percent = transparentCount / totalCount

i从 3 开始是取 alpha 通道,每次跳 16 个字节相当于每 4 个像素取一次。这种方式虽然损失了一点点精度,但对交互判断完全够用。当percent超过 0.5,就可以自动把上层 Canvas 隐藏,直接展示底层奖品。我上一篇系列文章里写过这个很省性能的轮询方案,这里就不展开细讲了。

4. 实战二:纹理背景上的镂空文字

4.1 一个更常见的需求:让视频从文字笔画中透出

回到我开头提到的需求:封面图上有一行字,字的笔画处透明,背景视频从笔画中透出来。这种效果如果只靠 PNG 遮罩会很麻烦,因为视频区域是动态的,遮罩必须精确贴合。用 Canvas 实现就简单很多。

思路是:不在 Canvas 里画底层视频,而是让 Canvas 作为一个上层悬浮层,只绘制"半透明遮罩"和"镂空文字"。遮罩层布满整个 Canvas,文字区域通过destination-out擦掉,于是字体笔画处的 Canvas 像素变成透明,系统会把底下的视频内容透出来。这种方案的好处是底层视频完全不参与 Canvas 的像素运算,不会造成额外性能开支。

4.2 一步一步写出镂空文字

在onReady回调中,按以下顺序绘制:

.onReady(() => { const ctx = this.ctx // 1. 铺一层半透明遮罩 ctx.fillStyle = 'rgba(0, 0, 0, 0.7)' ctx.fillRect(0, 0, 300, 200) // 2. 切换到擦除模式 ctx.globalCompositeOperation = 'destination-out' ctx.font = 'bold 44vp sans-serif' ctx.textAlign = 'center' ctx.textBaseline = 'middle' // 3. 文字区域被挖空 ctx.fillText('限时优惠', 150, 100) // 4. 恢复默认合成,避免影响其他绘制 ctx.globalCompositeOperation = 'source-over' })

这段代码运行后,画布上只剩一个半透明的黑色遮罩,中间文字的笔画区域变得完全透明。如果底层有视频,视频内容会从笔画中透出,视觉上非常干净。这里的关键是顺序:先画遮罩,再挖文字。顺序反过来的话,先挖了文字,再铺遮罩,会把透明区域重新盖住,效果就没了。

4.3 描边镂空、半透明镂空、多文字排版

fillText做的是"整个字形内部被挖空",如果你只想要文字轮廓被挖空,内部仍然保留遮罩,可以使用strokeText。这个技巧在需要低调但精致的标题效果时很好用,视觉上像是一个个透明描边字嵌在遮罩里。

ctx.globalCompositeOperation = 'destination-out' ctx.lineWidth = 4 ctx.strokeText('限时优惠', 150, 100) ctx.globalCompositeOperation = 'source-over'

如果想做"半透明镂空",也就是文字区域不是完全透明,而是半透明,可以把fillStyle改为rgba(0, 0, 0, 0.5)。根据前面讲的公式,最终透明度等于原透明度乘以 0.5。这种效果适合做"压暗但保留底层细节"的文字水印。我在实际项目中用到过这个方法:把文字区域压暗一半,但仍然保留视频的光影变化,比完全挖空更有高级感。

多文字排版时,可以用ctx.font分别设置每一段文字的字体和大小,也可以先save()再修改属性,画完restore()。但需要注意,save/restore不会保存globalCompositeOperation吗?在标准 Canvas 里,globalCompositeOperation是会被保存和恢复的。不过为了代码可读性,我仍然建议在关键操作后用一行注释把模式改回来,而不是完全依赖restore。这样后续维护的人一看就明白当前状态。

5. 绕不开的坑:坐标单位、重绘机制、合成状态

5.1 坐标单位 vp 和像素密度换算

HarmonyOS 的 UI 布局默认使用vp作为逻辑单位,Canvas 内的坐标系统同样遵循这个规则。这意味着你在 Canvas 里画的100,在不同的物理屏幕上占据的物理尺寸基本一致,但对应的像素数量不同。大多数业务场景直接用 vp 坐标就好,不需要手动换算。

但如果你要基于getImageData做像素级运算,就一定要注意单位差异。比如在getImageData(0, 0, 300, 200)这行代码里,宽高传的是逻辑坐标,但返回的data长度会随着屏幕密度变化。假设一台高密度设备屏幕,300vp 对应的物理像素可能是 900px,那么getImageData返回的缓冲区就是 900 × 600 的 RGBA 数组。如果你还想用 300 去计算宽高,会采样错乱。这时候正确的做法是通过系统提供的密度值做换算,或者干脆只用getImageData返回的width和height来遍历,不自己去假设。我在真机调试时遇到过这个问题,模拟器上是正常的效果,换到高密度真机后刮开比例判断直接失灵,排查了很久才发现是这个原因。

5.2 globalCompositeOperation 不重置,后续绘制全被腐蚀

做刮刮乐时,我踩过最经典的坑就是忘了把globalCompositeOperation改回source-over。当时我在一页里同时用了 Canvas 和普通组件,结果发现 Canvas 上后续绘制的所有文字和图案都变成了"橡皮擦",把前面画好的背景擦得乱七八糟。

原因就是globalCompositeOperation是 Canvas 上下文里的状态,设置一次后一直生效,直到再次修改。所以每次使用destination-out画完后,要么立即恢复成source-over,要么在开始一段完整绘制逻辑前先显式设置一次。我个人的习惯是封装成一个小工具函数:

private eraseShape(callback: () => void) { const ctx = this.ctx ctx.globalCompositeOperation = 'destination-out' callback() ctx.globalCompositeOperation = 'source-over' }

不要小看这个细节,它带来的 bug 往往非常隐蔽。因为你可能是在某一帧开启了擦除模式,下一帧正常绘制时没有意识到当前模式还没有重置,于是画面出现奇怪的透明区域,看起来像是缓存问题,实际是合成状态残留。

5.3 为什么 clearRect 代替不了 destination-out

有开发者会问:既然clearRect也能把矩形区域变成透明,为什么还要用destination-out?差别有两个。第一,clearRect只能清理矩形,想清理任意路径、曲线、文字,你得一步一步用大量矩形近似,效率低且边缘粗糙。第二,clearRect是"直接清空",不参与 alpha 混合。它没法实现我们前面提到的半透明镂空,也没有办法利用lineWidth、lineCap这些路径属性来产生平滑的擦除轨迹。

当然,在某些简单场景下clearRect更快。比如一个固定位置的方形洞,不需要复杂交互,可以用它替代。但一旦形状变成圆形、文字、不规则轨迹,destination-out就是更合适的工具。

5.4 离屏 Canvas 与 PixelMap 的高阶处理

复杂场景下,我建议多准备一层"离屏画布"。比如做一个带刮擦动画的组件,不希望每一帧都重绘整个界面,就可以先把静态背景画到一个离屏 Canvas 上,然后把最终结果显示到主 Canvas。这样主 Canvas 只负责和用户交互的部分,性能压力小很多。

在 HarmonyOS 里,离屏 Canvas 的写法会因为 API 版本不同有些差异,核心思路是把一个 Canvas 作为绘制目标,绘制完成后通过drawImage把它贴到主画布上。如果你遇到接口限制,还有一个迂回方案:把离屏绘制结果转成PixelMap,再用drawImage绘制到目标画布。PixelMap 的主要优势是可以按像素操作,适合做局部滤镜、颜色替换。但注意 PixelMap 的内存占用比普通 Canvas 高,处理大尺寸图片时一定要及时释放。

6. 从镂空到进阶玩法:进度环、图表、蒙版

6.1 圆环进度中心的镂空

抠掉了文字之后,你会发现destination-out其实是一把通用工具。比如做一个带中心孔的进度环,不需要引入复杂的 SVG,直接先画两个圆,内圆用destination-out挖掉,就能得到一个圆环。更进一步,把进度的一部分用不同角度的弧线绘制,就能做出有刻度的环形进度图。

ctx.arc(150, 150, 100, 0, Math.PI * 2) ctx.fillStyle = 'rgba(0,0,0,1)' ctx.fill() ctx.globalCompositeOperation = 'destination-out' ctx.arc(150, 150, 80, 0, Math.PI * 2) ctx.fill() ctx.globalCompositeOperation = 'source-over'

这段代码把外圆和内圆擦除后,留下的就是一个圆环。如果要显示进度,可以把第二个圆弧改成百分比弧度,这样进度条的视觉主体就出来了。很多图表库的底层也是这么做的,只是套了一层贝塞尔动画封装。

6.2 图片蒙版镂空与性能取舍

图片蒙版是另一个常见场景。比如要在一个矩形照片上做出"撕纸"效果,或者让图片边缘呈现不规则的锯齿,传统做法是准备一张带透明通道的蒙版图。但动态生成蒙版时,用 Canvas 更灵活:先drawImage把照片画到画布,再在边缘区域用destination-out擦除若干随机形状,形成一种不规则的残破边缘。

这种方案的性能瓶颈主要在drawImage的耗时和大画布的内存占用。如果照片尺寸非常大,建议先压缩到目标显示尺寸再绘制,不要直接把原图画进 Canvas。我在真机上测过,2000px 以上的图片在低端设备上频繁重绘会明显掉帧,压缩到 1080p 以内后视觉几乎无损,流畅度提升很明显。

6.3 硬件渲染模式遇到合成异常时的处理

HarmonyOS 的 Canvas 有硬件渲染和软件渲染两种模式。默认情况下会优先硬件加速,大部分globalCompositeOperation都能正常工作。但我在某一次真机调试中发现,某些旧系统版本的硬件渲染对destination-out和shadow的组合支持不完整,擦除区域会出现黑色残影。

遇到这种问题,可以在 Canvas 上显式设置软件渲染模式:

Canvas(this.ctx) .renderMode(RenderMode.SOFTWARE) .width(300) .height(200)

软件渲染牺牲一点性能换取合成准确性。我只建议在真机出现异常时开启,不要全局默认使用。另外如果你同时使用了shadowBlur和destination-out,要特别小心。源图形的阴影可能也会参与擦除计算,导致镂空区域周围出现一圈不该有的透明痕迹。我会在擦除前把阴影关闭,擦完后再恢复。

最后说一个我惯用的调试小技巧。开发阶段可以用一个开关来开启"调试模式",在擦除前把源绘制颜色设成红色。这样如果合成模式没有生效,你看到的不是透明区域,而是一块醒目的红色形状,马上就能定位问题是出在模式设置、坐标计算还是绘制顺序上。上线前再把这个开关关掉,不影响任何业务逻辑,但排查效率高很多。希望这篇实战记录,能让你在 HarmonyOS 6 里做镂空效果时少走几趟弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询