HarmonyOS 鸿蒙 BorderBeam 边框光束实战 —— 一次「还原效果却跑不动」的性能警示
一、前言:Web 上 30 行 CSS,鸿蒙上的一场硬仗
先看上游 border-beam(Jakubantalik/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 动画角度 */
}
关键就三个东西:
-
mask-composite: exclude/xor—— 镂空 content-box,只留一圈边框; -
@property+conic-gradient—— 可动画的扇形「旅行窗口」; -
filter: blur—— 一次把整层糊开,色斑融合成柔和光晕。
这套东西在浏览器里由 GPU compositor 高效执行,所以「又柔又流畅」。
问题是:ArkUI 这三样一个都没有等价物。
|
Web 能力 |
ArkUI 现状 |
|---|---|
|
|
无对应 API |
|
可动画 CSS 自定义属性 |
无 |
|
廉价 |
无等价廉价手段 |
|
|
|
于是 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" 的硬砍,注释里都留着痕迹:
|
位置 |
减负前 |
减负后 |
|---|---|---|
|
|
约 220 fills/帧 |
14 samples 单次 soft fill |
|
|
4 层 |
2 层 |
|
|
40 × bloom×2 |
16 samples × 1 fill |
|
|
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 保留名」在多组件里反复出现,FlowAvatar 用 avatarSize、ThinkingOrbs 用 orbSize,是值得单独总结的一条横切经验。)
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)
-
OffscreenCanvas / 低频更新晕染层 + 高频只更新流光核(分层刷新,最可能的方向)。
-
drawing / RenderNode / 自定义效果(若 blur 能力更接近 CSS)。
-
只交付 Md 边环流光,砍掉 Pulse Out 大晕,做轻量生产路径。
-
静态代表帧 / 短循环预烘焙,减实时 hue 与 fill。
-
仪器化:单模式 fills/帧 与 ms/帧 日志(当前靠主观卡顿)。
七、踩坑速查
|
现象 |
根因 |
修复/说明 |
|---|---|---|
|
整卡糊彩 / 边框看不见 |
光画在背后 / 无 ring mask |
Canvas 叠在 content 之上 + |
|
真机极卡(150~250 fills/帧) |
用多层 radial fill 模拟 blur |
封板减负:14 samples / 2 层 bloom / ≤30fps |
|
减负后变稀、融合差 |
减 fill 就是减 blur 近似层数 |
观感与帧率不可兼得,记录为 Finding |
|
Line 底边循环瞬移 |
|
改 ping-pong |
|
Pulse In 整层清空 |
|
改沿周长向内采样 |
|
conic 扇区像切帧 |
扇区边缘过硬 |
用软 ramp 的 color stop |
|
|
ArkUI 保留 attribute |
改名 |
|
|
ArkTS 严格模式 |
|
|
列表多实例卡 |
多个 Canvas 动画并发 |
列表默认 |
八、写在最后
这篇不是「教你做一个炫酷的鸿蒙边框流光」,而是如实告诉你:这条路原样搬过来会撞墙。但它依然值得写、值得读,因为:
-
技术原理是可复用的:
destination-out掏孔、createConicGradient扇形遮罩、destination-in窗口,这些 Canvas 混合模式技巧在任何 Canvas 动画里都会用到。 -
性能直觉是通用的:
filter: blur这类「一次糊整层」的 GPU 操作,无法用「多层 soft fill」在 CPU 侧廉价近似——这是跨平台移植时最容易误判的假设。 -
工程姿态是对的:不是所有实验都该「成功上线」,把「冲突本身」作为结论沉淀,比硬凑一个半成品发布更有价值。
如果你确实要在鸿蒙做边框流光,我的建议是:先只做 Md/Sm 的「边环旅行光」(相对轻),把 PulseOutside 的大晕砍掉;或者等 OffscreenCanvas / RenderNode 能力成熟后再回来。别在「多层 radial fill 硬堆 blur」这条路上继续加预算了——E023 已经替你走到底了。
CSS 的 blur 是一次糊整层,Canvas 的 soft fill 是 N 层叠近似。
destination-out 掏孔、conic 造窗、destination-in 留扇——技巧都能用,但成本会还回来。
观感与帧率冲突时,先保可运行,再把冲突写进结论。
更多推荐




所有评论(0)