
做应用开发的人应该都有这种需求界面上某一块区域要消失露出底下的内容。比如刮刮乐、卡片解锁、异形遮罩、文字挖空本质上都是在图层上打洞。最近在 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 / totalCounti从 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 里做镂空效果时少走几趟弯路。