一、前言:Web 上 30 行 CSS,鸿蒙上的一场硬仗

先看上游 border-beamJakubantalik/border-beam,MIT)的核心思路,它本质上是一套 CSS 组合拳:

/* 伪元素画边框环,用 mask-composite: exclude 掏掉中心 */
.border-beam::after {
  background: conic-gradient(...);          /* 扇形旅行窗口 */
  -webkit-mask: linear-gradient(...) padding-box,
               linear-gradient(...);
  -webkit-mask-composite: xor;              /* 只保留边框环 */
  filter: blur(8px~22px);                   /* 一次糊整层,做出晕染 */
  animation: beam-rotate linear infinite;   /* @property 动画角度 */
}

关键就三个东西:

  1. mask-composite: exclude/xor —— 镂空 content-box,只留一圈边框;

  2. @property + conic-gradient —— 可动画的扇形「旅行窗口」;

  3. filter: blur —— 一次把整层糊开,色斑融合成柔和光晕。

这套东西在浏览器里由 GPU compositor 高效执行,所以「又柔又流畅」。

问题是:ArkUI 这三样一个都没有等价物。

Web 能力

ArkUI 现状

mask-composite: exclude/xor

无对应 API

可动画 CSS 自定义属性 @property

廉价 filter: blur(整层糊)

无等价廉价手段

conic-gradient 遮罩

CanvasRenderingContext2DcreateConicGradient,但需手工编排

于是 E023 的研究问题变成:

在无 CSS mask-composite / @property / 廉价 filter: blur 的 ArkUI 上,能否用 Canvas 复刻 border-beam 的包装器契约与晕染气质,并在真机上可接受帧率?

答案是:能复刻(观感一度接近上游),但「可接受帧率」做不到——两个目标在纯 Canvas 路径上冲突。 下面完整拆解这条路的每一步。


二、架构:内容包装器 + 叠层 Canvas 效果壳

2.1 心智模型

BorderBeam 不是「画一个会发光的边框」,而是一个内容包装器(content wrapper):把用户内容包住,再在内容之上叠一层透明的 Canvas 效果壳。

@LocalBuilder content          ← 用户真实内容(卡片、按钮……)
  → Stack { content; Canvas(hitTest None) }   ← 光叠在内容之上
  → onAreaChange → 拿到 content 尺寸
  → DisplaySync ≤30fps + 32ms 跳帧
  → Painter(mode, palette, theme, strength, tSec)

关键点:效果层在内容之上,不是背后。

上游是 pointer-events: none 的 root 叠层遮罩,Canvas 必须叠在 content 上面hitTestBehavior: HitTestMode.None),并用 destination-out 掏掉中心,否则不透明的卡片会盖死整圈光。

2.2 五态 + 四色板

组件覆盖五种视觉形态(BorderBeamSize):

Size

语义

Sm / Md

边框环旅行光(ring mask + conic 扇区)

Line

底边旅行光 + 底部内晕

PulseInner

边框内侧呼吸晕 + 边缘流光

PulseOutside

外侧大光晕 + 边框流光

四套色板(BorderBeamColorVariant):Colorful / Mono / Ocean / Sunset;主题 Auto / Light / Dark(Auto 由宿主 darkSurface 注入,组件不读 AppStorage)。

2.3 分层(与 GrokBot、FlowAvatar 一致)

borderbeam/
  BorderBeamModels.ets      enums / theme / defaults
  BorderBeamPalettes.ets    色斑表 + pulse outer layouts(锁 pin)
  BorderBeamProfiles.ets    size × theme opacity 表
  BorderBeamCore.ets        perimeter / pingPong / hueRotate / rounded path
  BorderBeamPainter.ets     五态绘制(含性能减负默认)
  BorderBeam.ets            Stack + Canvas + DisplaySync 生命周期

Core / Palettes / Profiles 全部纯函数可单测,Painter 只画一帧,Component 管生命周期——这套「算法与视图分层」范式在项目里反复出现,是它即使封板也「工程资产可保留」的原因(相关通用范式我会另写一篇《Custom Canvas 动效分层范式》)。


三、手工复刻 CSS:三个 Canvas 等价手段

3.1 destination-out 掏孔:替代 mask-composite: exclude

CSS 的 mask-composite: exclude 是「只保留边框环」。Canvas 里的等价做法是用混合模式把中心掏掉

function punchRingHole(ctx, params, inset): void {
  const pad = Math.max(1, inset);
  const iw = params.width - pad * 2;
  const ih = params.height - pad * 2;
  const ir = Math.max(0, params.borderRadius - pad);
  const prev = ctx.globalCompositeOperation;
  ctx.globalCompositeOperation = 'destination-out';  // 用洞「擦」掉已画内容
  ctx.fillStyle = '#000000';
  fillRoundedRect(ctx, pad, pad, iw, ih, ir);         // 掏一个内缩的圆角矩形
  ctx.globalCompositeOperation = prev;
}

destination-out 的意思是:用源(这个内缩矩形)的 alpha 反向擦除目标(已画的彩色层)——于是内部被清空,只剩边缘一圈(inset 决定的环宽)。

3.2 createConicGradient + destination-in:替代 @property 角度动画 + conic mask

上游用 @property 让 conic 渐变的角度可动画,形成「一束光扫过边框」。Canvas 用 createConicGradient 手动算起始角度:

function applyConicSectorMask(ctx, params, progress): void {
  const cx = params.width / 2;
  const cy = params.height / 2;
  // Canvas conic 从 +x(东)起,CSS 通常从顶部起 → 偏移 -90°
  const start = progress * Math.PI * 2 - Math.PI / 2;
  const grad = ctx.createConicGradient(start, cx, cy);
  // 用软 ramp 让扇区边缘不过硬(否则循环回绕会「切帧」)
  grad.addColorStop(0.0, 'rgba(0,0,0,0)');
  grad.addColorStop(0.18, 'rgba(0,0,0,0)');
  grad.addColorStop(0.28, 'rgba(0,0,0,0.15)');
  grad.addColorStop(0.38, 'rgba(0,0,0,0.55)');
  grad.addColorStop(0.48, 'rgba(0,0,0,1)');
  grad.addColorStop(0.78, 'rgba(0,0,0,1)');
  grad.addColorStop(0.88, 'rgba(0,0,0,0.55)');
  grad.addColorStop(0.94, 'rgba(0,0,0,0.15)');
  grad.addColorStop(1.0, 'rgba(0,0,0,0)');

  ctx.globalCompositeOperation = 'destination-in';   // 只保留扇区内的内容
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, params.width, params.height);
  ctx.globalCompositeOperation = prev;
}

destination-in 让「已经画好的彩色环」只保留在 conic 渐变 alpha 高的那一扇,于是视觉上就是「一束光沿边框旅行」。

3.3 多层 radial soft fill:替代 filter: blur —— 这是性能灾难的源头

CSS 的 filter: blur(8~22px) 是一次把整层糊开,GPU 高效完成。Canvas 没有廉价等价物,只能用多层径向渐变去「堆」出 blur 的柔和观感

// 单个 soft ellipse:径向渐变从中心 alpha 高 → 边缘透明
function drawSoftEllipse(ctx, cx, cy, rx, ry, color, alpha): void {
  const rMax = Math.max(rx, ry);
  const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, rMax);
  grad.addColorStop(0,   rgbToRgba(color, alpha * 0.7));
  grad.addColorStop(0.55, rgbToRgba(color, alpha * 0.22));
  grad.addColorStop(1,   rgbToRgba(color, 0));
  ctx.fillStyle = grad;
  ctx.beginPath();
  ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
  ctx.fill();
}

// 用 2 次不同半径的 soft fill 叠出「模糊」感
function drawBloomBlob(ctx, cx, cy, rx, ry, color, alpha): void {
  drawSoftEllipse(ctx, cx, cy, rx * 1.85, ry * 1.85, color, alpha * 0.35);
  drawSoftEllipse(ctx, cx, cy, rx * 0.85, ry * 0.85, color, alpha * 0.55);
}

这里就是问题的核心。 一次 createRadialGradient + ellipse + fill 本身不贵,但「模糊」的语义需要很多层、很多锚点去近似:

成本 ∝ 层数 N × 锚点数 × 采样点

上游一次 blur 糊整层,我们用 N 层叠近似。「好看」的 PulseOutside 配置可达 约 150~250 次 fill/帧——这就是真机极卡的根源。


四、性能真相:卡死在哪里,又怎么「减负」

4.1 观感迭代的时间线(真机实录)

阶段

现象

初版

光画在背后 / 无 ring mask,整卡糊彩或看不见

叠层 + punch

Md/Sm 边环可读;环曾偏粗后收细

多层 bloom

Line / Pulse Out 外晕接近上游气质

色相流动

加 continuous hue + palette morph 后「同位置变色」改善

观感达标时

特别卡(每帧上百次径向填充)

性能减负后

可运行,但晕染变稀、融合变差,不如高开销版好看

4.2 「流畅优先」的减负斩

封板时为了保住「可运行」,Painter 里做了大量 "was X → now Y" 的硬砍,注释里都留着痕迹:

位置

减负前

减负后

drawTravelBeam

约 220 fills/帧

14 samples 单次 soft fill

drawBloomBlob

4 层

2 层

drawPerimeterInnerBloom

40 × bloom×2

16 samples × 1 fill

paintPulseOutside

200+ fills

约 40 fills(7 bloom + 8 core + 7 bridge + travel)

帧率上限

追求 60

expected 30 + <32ms 跳帧

源码注释最诚实的一句:

/** Simple frame skip: paint at most ~30fps even if DisplaySync fires faster. */
private onFrame(): void {
  const now = Date.now();
  if (this.lastPaintWallMs > 0 && now - this.lastPaintWallMs < 32) {
    return;   // 32ms 才能画一帧 ≈ 30fps 封顶
  }
  this.lastPaintWallMs = now;
  this.paintAt(this.timeSeconds());
}

也就是说,即便 DisplaySync 能给更多帧,BorderBeam 也主动压到 30fps,因为每一帧的 Canvas soft fill 成本太高,60fps 只会把成本翻倍而视觉收益极低。

4.3 减负的代价

减负不是免费的——它牺牲的就是「像 Web 的那种柔和」:

减负后可运行,但晕染变稀、融合变差,不如高开销版本好看。

这就是封板结论的核心矛盾,原文写得很直白:

观感 ≈ 上游 需要多层 soft fill + 色相流动 → 真机极卡
减负到可流畅晕染变稀、融合变差


五、连带踩坑:除了性能,还有这些

5.1 @BuilderParam 必须用 @LocalBuilder 桥接

@BuilderParam content: () => void = this.defaultContent;

在 ArkTS 严格模式下,直接用 content: () => { UI } 会编译失败,需要 @LocalBuilder 桥接。这是 E023 里记录的一条,也是 GrokBot 系列没踩到但很容易踩的坑。

5.2 保留属性名冲突

ArkUI 的 size / borderRadius / brightness 都是保留 attribute,所以公开 API 全部改名:

@Prop beamSize: BorderBeamSize = BorderBeamSize.Md;      // 不是 size
@Prop beamRadius: number = -1;                            // 不是 borderRadius
@Prop beamBrightness: number = -1;                        // 不是 brightness

(这个「ArkUI 保留名」在多组件里反复出现,FlowAvataravatarSizeThinkingOrbsorbSize,是值得单独总结的一条横切经验。)

5.3 Line 底边循环瞬移

Line 模式底边光如果直接用 progress % 1 做线性映射,循环回绕处会瞬移(从端点跳回起点)。修法是改用 ping-pong:

function pingPong(phase: number): number {
  return (1 - Math.cos(2 * Math.PI * phase)) / 2;  // 0..1..0 平滑往返
}

5.4 Pulse In 的 evenodd 在部分机整层清空

PulseInner 曾用 destination-in + evenodd 做边缘带,但在部分真机上整层被清空。最终改为「沿周长向内采样」的方式,去掉 evenodd


六、封板结论:一条「已经走到头」的路线

E023 的最终状态是 Sealed(封板),这是 ArkUILab「Research first」气质的体现——不是硬撑着写「成功」,而是把「此路不通 / 暂时不通」也作为一条有价值的结论沉淀下来:

维度

结论

方向价值

值得研究;契约、分层、色板锁定可沉淀

工程资产

可保留:Models / Profiles / Palettes / Core / Lab / 单测

生产推荐

暂不推荐默认用于列表 / 多实例主路径

核心矛盾

观感与帧率在该实现路径上冲突

它没有变成垃圾代码,而是变成了一个对照样板(baseline):后续如果有人想在新渲染路径(如 OffscreenCanvas 分层刷新、RenderNode 自定义效果、只交付轻量 Md 边环)上重做,可以直接拿这版做视觉与性能的对照基准。

六个没实施的后续路线(Open Questions)

  1. OffscreenCanvas / 低频更新晕染层 + 高频只更新流光核(分层刷新,最可能的方向)。

  2. drawing / RenderNode / 自定义效果(若 blur 能力更接近 CSS)。

  3. 只交付 Md 边环流光,砍掉 Pulse Out 大晕,做轻量生产路径。

  4. 静态代表帧 / 短循环预烘焙,减实时 hue 与 fill。

  5. 仪器化:单模式 fills/帧 与 ms/帧 日志(当前靠主观卡顿)。


七、踩坑速查

现象

根因

修复/说明

整卡糊彩 / 边框看不见

光画在背后 / 无 ring mask

Canvas 叠在 content 之上 + destination-out 掏孔

真机极卡(150~250 fills/帧)

用多层 radial fill 模拟 blur

封板减负:14 samples / 2 层 bloom / ≤30fps

减负后变稀、融合差

减 fill 就是减 blur 近似层数

观感与帧率不可兼得,记录为 Finding

Line 底边循环瞬移

progress % 1 线性回绕

改 ping-pong (1-cos)/2

Pulse In 整层清空

destination-in+evenodd 部分机失效

改沿周长向内采样

conic 扇区像切帧

扇区边缘过硬

用软 ramp 的 color stop

size/borderRadius 报错

ArkUI 保留 attribute

改名 beamSize/beamRadius/beamBrightness

content 编译失败

ArkTS 严格模式

@BuilderParam + @LocalBuilder 桥接

列表多实例卡

多个 Canvas 动画并发

列表默认 active=false/paused=true,实例 ≤1~2


八、写在最后

这篇不是「教你做一个炫酷的鸿蒙边框流光」,而是如实告诉你:这条路原样搬过来会撞墙。但它依然值得写、值得读,因为:

  1. 技术原理是可复用的destination-out 掏孔、createConicGradient 扇形遮罩、destination-in 窗口,这些 Canvas 混合模式技巧在任何 Canvas 动画里都会用到。

  2. 性能直觉是通用的filter: blur 这类「一次糊整层」的 GPU 操作,无法用「多层 soft fill」在 CPU 侧廉价近似——这是跨平台移植时最容易误判的假设。

  3. 工程姿态是对的:不是所有实验都该「成功上线」,把「冲突本身」作为结论沉淀,比硬凑一个半成品发布更有价值。

如果你确实要在鸿蒙做边框流光,我的建议是:先只做 Md/Sm 的「边环旅行光」(相对轻),把 PulseOutside 的大晕砍掉;或者等 OffscreenCanvas / RenderNode 能力成熟后再回来。别在「多层 radial fill 硬堆 blur」这条路上继续加预算了——E023 已经替你走到底了。

CSS 的 blur 是一次糊整层,Canvas 的 soft fill 是 N 层叠近似。
destination-out 掏孔、conic 造窗、destination-in 留扇——技巧都能用,但成本会还回来。
观感与帧率冲突时,先保可运行,再把冲突写进结论。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐