在这里插入图片描述

HarmonyOS技术精讲-ArkGraphics 2D(方舟2D图形服务)第五篇:色彩与风格——渐变、阴影与滤镜效果

开篇:别让画面太“素”

HarmonyOS NEXT 开发中,画布 API 的渐变、阴影和滤镜能力,很多人只是简单用过。但实际开发时,你会发现:官方示例里一个渐变圆画得很漂亮,但放到真实项目中,阴影没出来、滤镜不生效、合成模式结果和预期完全相反——这些问题并不少见。

这篇文章主要解决三个问题:

  1. 渐变:线性渐变和径向渐变在 ArkUI Canvas 下的正确使用方式,特别是 addColorStop 的坐标计算。
  2. 阴影shadowBlurshadowOffset 在实际应用中的坑,比如为什么阴影在真机上会消失。
  3. 滤镜与合成filterglobalCompositeOperation 的常见误用,以及如何做照片滤镜和镂空效果。

目标很明确:你跟着做完,能直接在项目里用。

它解决什么问题

先搞清楚本质。

能力 解决什么问题 适用场景 不适用场景
渐变(Linear/Radial) 用颜色过渡代替纯色填充,增加立体感和质感 按钮背景、进度条、仪表盘、弹窗遮罩 简单色块、大量渐变叠加(CPU开销大)
阴影 模拟光照下的投影或发光效果 文字标题、图标漂浮感、卡片层级区分 动态高频刷新场景(阴影触发重绘慢)
滤镜 对已绘制的像素进行后处理(模糊、亮度、对比度) 图片美化、实时滤镜效果、模糊背景 高精度图像处理(Canvas filter精度有限)
合成模式 控制新绘图形与已有内容的混合方式 遮罩、相册叠加效果、切片叠影 复杂颜色叠加(运算结果难预测)

ArkGraphics 2D 的 Canvas 能力,基本对标 Web Canvas 的 2D 接口,但在状态管理上和 ArkUI 的组件刷新机制有绑定,所以有些行为需要额外注意。

环境说明

DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机 / 平板

核心实现

我们从最简单的渐变开始。

1. 基础渐变:线性渐变

首先创建一个 Canvas 组件,然后在其 onReady() 回调中获取 CanvasRenderingContext2D

// 入口页面 Index.ets
@Entry
@Component
struct Index {
  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)

  build() {
    Row() {
      Canvas(this.context)
        .width('100%')
        .height('100%')
        .onReady(() => {
          this.drawLinearGradient()
        })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  drawLinearGradient() {
    const ctx = this.context
    // 1. 创建渐变对象,定义起始和结束坐标
    const gradient = ctx.createLinearGradient(50, 50, 250, 250)
    // 2. 设置颜色过渡点
    gradient.addColorStop(0, '#FF6B6B')   // 起始色
    gradient.addColorStop(0.5, '#FFE66D') // 中间色
    gradient.addColorStop(1, '#4ECDC4')   // 最终色

    // 3. 应用为填充样式
    ctx.fillStyle = gradient
    ctx.fillRect(50, 50, 200, 200)
  }
}

关键说明:

  • createLinearGradient(x0, y0, x1, y1) 的坐标是相对于 Canvas 左上角原点,而不是相对填充图形位置。很多人以为 addColorStop 传百分比是相对矩形,实际上它是相对渐变的整体插值范围。
  • 如果你想让渐变从矩形左上角到右下角,渐变的起止坐标应该和矩形顶点对齐,而不是随意写。
  • addColorStop 的第一个参数是 0 到 1 的浮点数,代表渐变插值位置。超出范围的会被截断。

2. 径向渐变:做一个3D球体

径向渐变常用来模拟球体的光照效果。

// 画一个3D球体
drawSphere(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) {
  // 径向渐变的参数:起始圆心、起始半径、终止圆心、终止半径
  // 这里起始圆心偏向左上,制造光源效果
  const radGrad = ctx.createRadialGradient(
    cx - r * 0.3, cy - r * 0.3, r * 0.1,  // 光源高光点位置及小半径
    cx, cy, r                                // 球体中心及半径
  )
  radGrad.addColorStop(0, '#FFFFFF')   // 高光白色
  radGrad.addColorStop(0.3, '#FF6B6B') // 中间过渡
  radGrad.addColorStop(1, '#8B0000')   // 暗部深红

  ctx.beginPath()
  ctx.arc(cx, cy, r, 0, Math.PI * 2)
  ctx.fillStyle = radGrad
  ctx.fill()
}

注意事项:

  • 径向渐变的两个圆,如果起始半径和终止半径比例不对,容易产生“光圈”效应,看起来不像球体。经验值:起始半径是终止半径的 1/10 到 1/5。
  • 起始圆心的偏移量决定了光照方向。偏移量太大,高光过于极端;太小,球体太平。

3. 阴影效果:发光文字

阴影属性在 Canvas 上是全局状态,一旦设置,后面所有绘制操作都会带上阴影,直到重置。

drawGlowText(ctx: CanvasRenderingContext2D) {
  // 设置阴影
  ctx.shadowColor = '#00BFFF'       // 阴影颜色(发光色)
  ctx.shadowBlur = 20               // 模糊程度
  ctx.shadowOffsetX = 0
  ctx.shadowOffsetY = 0

  ctx.font = 'bold 48px sans-serif'
  ctx.fillStyle = '#FFFFFF'
  ctx.textBaseline = 'top'
  ctx.fillText('HarmonyOS', 50, 100)

  // 重置阴影,避免影响后续绘制
  ctx.shadowColor = 'transparent'
  ctx.shadowBlur = 0
  ctx.shadowOffsetX = 0
  ctx.shadowOffsetY = 0
}

真实项目中的坑:

onReady 回调里绘制一次没问题。如果页面有状态刷新触发重新绘制(比如配合 @State 变量重绘),你会发现阴影可能不见了。原因是 ArkUI 的 Canvas 上下文在某些场景下会被重新创建,而 shadowColor 这样的属性在重建后不会保持旧值。所以每次 onReady 或重绘时,务必重新设置所有上下文属性。

4. 滤镜:照片滤镜

同时还可以使用滤镜操作。

// 应用滤镜:照片效果
applyPhotoFilter(ctx: CanvasRenderingContext2D) {
  // 假设已经画好一张图片或内容
  // 设置滤镜
  ctx.filter = 'brightness(1.2) contrast(1.5) saturate(0.8)'
  // 在这个滤镜作用下绘制内容
  ctx.drawImage(imageBitmap, 0, 0, 300, 300)

  // 清除滤镜,避免影响后续
  ctx.filter = 'none'
}

参数说明:

滤镜函数 作用 参数范围(典型值)
blur(px) 高斯模糊 0 ~ 20px
brightness(ratio) 亮度 0 ~ 2(1为原图)
contrast(ratio) 对比度 0 ~ 2
saturate(ratio) 饱和度 0 ~ 2(1为原图)
grayscale(ratio) 灰度 0 ~ 1(1完全灰度)
sepia(ratio) 怀旧色 0 ~ 1

注意:

  • filter 属性在 HarmonyOS NEXT 上支持度不错,但多个滤镜链式调用时,性能下降明显。如果只是简单的亮度调整,建议用 globalCompositeOperation 替代。
  • 滤镜绘制完成后,一定要重置为 'none',否则后续所有绘制都会带滤镜。

5. 合成模式:镂空效果

globalCompositeOperation 控制新绘制的图形如何与已有内容混合。

drawCutout(ctx: CanvasRenderingContext2D) {
  // 先画一个蓝色矩形作为背景
  ctx.fillStyle = '#0000FF'
  ctx.fillRect(50, 50, 200, 200)

  // 设置合成模式为 'destination-out':新图形覆盖的区域变透明
  ctx.globalCompositeOperation = 'destination-out'

  // 在新的模式下,画一个圆
  ctx.beginPath()
  ctx.arc(150, 150, 60, 0, Math.PI * 2)
  ctx.fillStyle = '#FF0000' // 颜色无所谓,'destination-out' 只看形状
  ctx.fill()

  // 重置合成模式
  ctx.globalCompositeOperation = 'source-over'
}

这段代码运行后,蓝色矩形中间会有一个圆形透明区域,实现镂空效果。

常见模式对比:

模式 效果
source-over 默认,新图形覆盖旧图形
destination-out 新图形区域使旧图形透明
source-atop 新图形只绘制在旧图形存在的区域
lighter 颜色叠加(变亮)
multiply 正片叠底(变暗)

踩坑记录

坑1:全局Shadow在真机上部分机型不生效

现象: 模拟器上阴影正常显示,但在部分真机上(尤其是中低端设备),阴影完全消失,只有图形本身。

原因:
Canvas 的阴影渲染在 GPU 上依赖硬件加速。部分设备在低功耗模式下,Canvas 硬件加速被降级,或者 shadowBlur 值过大导致超出硬件纹理限制。官方文档提到阴影最大模糊半径是 150px,但实际测试中,超过 100px 就会在某些设备上不渲染。

解决方案:

  • shadowBlur 控制在 80px 以内。
  • 如果必须大模糊半径,改用 filter: blur() 方式实现发光效果(更稳定)。
  • onReady 回调中,显式设置 Canvas 的硬件加速属性(如果有)或通过 context 读取 shadowBlur 是否被正确设置。

坑2:Canvas.drawImage 不支持传入 PixelMap 对象

现象: 想用滤镜处理一张图片,但 drawImage 只支持 ImageBitmap 类型,不支持直接传 PixelMap

原因:
ArkUI 的 Canvas API 设计时,drawImage 的参数来源仅限于 ImageBitmap(通过 createImageBitmap 创建)。从 @ohos.multimedia.image 获取的 PixelMap 并不能直接使用。

解决方案:
使用 image.createImageBitmap(pixelMap) 转换。注意 createImageBitmap 是异步的,需要在回调中绘制。

import image from '@ohos.multimedia.image'

async function drawImageFromPixelMap(ctx: CanvasRenderingContext2D, pixelMap: image.PixelMap) {
  const bitmap = await image.createImageBitmap(pixelMap)
  ctx.filter = 'brightness(1.5) contrast(1.2)'
  ctx.drawImage(bitmap, 0, 0, 300, 300)
  ctx.filter = 'none'
}

最佳实践

  1. 不要在 build() 中频繁创建 Canvas 相关对象。

    // ❌ 错误示例:在 build 中每次刷新都新建
    build() {
      Canvas(new CanvasRenderingContext2D(this.settings)) // 每次刷新都会新建
    }
    
    // ✅ 正确做法:context 在组件外创建一次
    private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
    

    原因:build() 在状态变化时会频繁执行,每次 new 都会销毁旧的上下文,导致已绘制的图形丢失。

  2. 每次绘制前,显式设置阴影、滤镜等全局属性。

    不要假设上一步重置了状态。ArkUI Canvas 的全局属性在两次 onReady 之间可能不保持。安全做法是在 draw 函数开头完整设置一次。

  3. 滤镜在实时绘制场景中,优先使用更轻量的方案。

    例如动态亮度变化,用 brightness 滤镜每帧重绘,CPU 消耗较高。如果只是固定滤镜效果,在首次绘制时初始化一次即可,不需要每帧都设置。

Demo 入口

所有代码集中在 Index.ets 文件中,包含以下效果:

  • 线性渐变矩形
  • 径向渐变3D球体
  • 发光文字
  • 照片滤镜(模糊+调色)
  • 镂空合成模式
@Entry
@Component
struct Index {
  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)

  build() {
    Scroll() {
      Column() {
        Canvas(this.context)
          .width('100%')
          .height(800)
          .onReady(() => {
            this.drawAll()
          })
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#2C3E50')
  }

  drawAll() {
    const ctx = this.context
    ctx.clearRect(0, 0, 400, 800)
    this.drawLinearGradient(ctx)
    this.drawSphere(ctx, 120, 300, 80)
    this.drawGlowText(ctx)
    this.drawPhotoFilter(ctx)
    this.drawCutout(ctx)
  }
  // 其他 draw 方法参见上文
}

FAQ

Q:为什么我设置了 shadowBlur,但文字阴影没有模糊效果,只有实心色块?

A:检查 shadowColor 是否设置了非透明颜色。如果 shadowColor'white' 且背景也是白色,阴影就看不见。另外,shadowBlur 的值不宜过大,超过 100 在某些设备上会被忽略。

Q:filterglobalCompositeOperation 可以一起用吗?

A:技术上可以,但顺序会影响结果。filter 是对当前绘图操作施加滤镜,而 globalCompositeOperation 是控制新绘制的图形与画布已有内容的混合。建议先画完无滤镜内容,再使用 globalCompositeOperation 进行合成,避免滤镜干扰混合结果。

Q:为什么模拟器和真机的阴影颜色不一样?

A:模拟器的 GPU 驱动和真机不同,部分设备的阴影渲染颜色校准存在差异。建议以真机为准,并在真机上测试所有阴影效果的视觉一致性。

源码

示例代码地址:项目地址

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐