鸿蒙开发(四)播放 Lottie 动画实战(Canvas 渲染 + 资源加载踩坑总结)

在鸿蒙 ArkUI 里播放 Lottie 动画,其实并不复杂,但一旦涉及图片资源、模块目录、封装复用,坑就会开始冒出来。

这篇文章就基于我实际项目中的使用,记录一下 @ohos/lottie 这个库的使用方式,以及几个非常关键、但官方文档里没说清楚的点。


一、项目地址

Lottie 的鸿蒙版本已经上架 OHPM:

👉 项目地址
https://ohpm.openharmony.cn/#/cn/detail/@ohos%2Flottie

整体实现思路是:
使用 Canvas 作为渲染容器,通过 lottie.loadAnimation 渲染 JSON 动画


二、核心实现思路说明

这个库本身是支持 svg / canvas 渲染的,在 ArkUI 里推荐直接用 canvas,稳定性和兼容性都更好。

但要注意:

👉 JSON 动画文件的位置

👉 JSON 里引用的图片资源怎么加载

下面重点说这两个地方。


三、JSON 文件放哪?这是第一个坑

结论先说

Lottie 的 json 文件,一定要放在 entry 模块的 ets 目录下

比如我这里的目录结构是:

复制代码
entry
 └─ ets
    └─ common
       └─ lottie
          └─ connecting.json

然后在代码中直接使用相对路径即可:

复制代码
path: 'common/lottie/connecting.json'

⚠️ 如果你把 json 放在:

  • rawfile
  • library 模块
  • service 模块

很大概率会直接加载失败(没有任何报错,非常难排查)。


四、JSON 里有图片资源怎么办?

这是第二个坑,也是最容易踩的坑

1️⃣ 情况一:图片在 JSON 同级目录(不推荐)

这种情况勉强能跑,但几乎没扩展性,而且一换模块就炸,不建议。


2️⃣ 情况二:图片资源放在 rawfile(推荐)

如果你的 Lottie 动画里引用了 png / jpg,那么就需要用到:

复制代码
imageAssetDelegate

通过这个回调,手动告诉 Lottie:图片资源去哪加载

我这里的做法是:

  • JSON 仍然放在 ets/common/lottie
  • 图片资源统一放在 module 的 rawfile 目录下

比如:

复制代码
entry
 └─ resources
    └─ rawfile
       └─ images
          ├─ base03.png
          ├─ cellphone03.png
          └─ instrument03.png

imageAssetDelegate 示例代码

复制代码
imageAssetDelegate: (imagePath: string, callback: AsyncCallback<PixelMap>) => {
  let inc = false
  let imageCache: string = ''
  const imageArray = this.image.jsonImage

  for (let i = 0; i < imageArray.length; i++) {
    const imageArrayCache = imageArray[i]
    inc = imagePath.includes(imageArrayCache)
    if (inc) {
      imageCache = imageArrayCache
      break
    }
  }

  if (!inc) {
    callback(null, null)
    return
  }

  imagePath = this.imagePrefix + imageCache

  this.getUIContext()
    ?.getHostContext()
    ?.resourceManager
    .getRawFileContent(imagePath, (err, raw_file_content) => {
      if (!err) {
        let image_source = image.createImageSource(raw_file_content.buffer)
        image_source.createPixelMap({
          editable: false,
          desiredPixelFormat: image.PixelMapFormat.RGBA_8888
        }, callback)
        image_source.release()
      } else {
        // 图片加载失败,不影响动画主体播放
        callback(err, null)
      }
    })
}

重点说明一下这里的逻辑:

  • JSON 里引用的图片路径是不可控的
  • 通过 includes 判断命中具体图片名
  • 再映射到 rawfile 的真实路径
  • 最终返回 PixelMap 给 Lottie

五、完整封装方案(可直接用)

为了避免每个页面都写一堆 Lottie 逻辑,我这边做了一层高度封装


1️⃣ JsonConst:统一管理动画资源

复制代码
export class JsonConst {

  // 胎监连接动画
  public static readonly FetalConnect: JsonImageParams = {
    jsonPath: 'common/lottie/connecting.json',
    jsonImage: ['base03.png', 'cellphone03.png', 'instrument03.png']
  }
}

export interface JsonImageParams {
  jsonPath: string
  jsonImage: string[]
}

2️⃣ JsonAnimView:通用 Lottie 播放组件

复制代码
import lottie, { AnimationItem } from '@ohos/lottie'
import { AsyncCallback } from '@kit.BasicServicesKit'
import { image } from '@kit.ImageKit'
import { JsonImageParams } from '../const/JsonConst'

@ComponentV2
export struct JsonAnimView {
  @Require @Param image: JsonImageParams
  @Param isLooper: boolean = false
  @Param imagePrefix: string = "images/"
  // 构建上下文
  private renderingSettings: RenderingContextSettings = new RenderingContextSettings(true)
  private canvasRenderingContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.renderingSettings)
  private animateItem: AnimationItem | null = null
  private hasLoaded: boolean = false // 防止 onReady 多次 load
  private isDestroyed: boolean = false

  aboutToDisappear(): void {
    this.destroy()
  }

  build() {
    Canvas(this.canvasRenderingContext)
      .width('100%')
      .height('100%')
      .onReady(() => {
        // 加载动画
        if (this.hasLoaded) {
          if (this.animateItem) {
            this.animateItem.resize()
          }
          return
        }
        this.canvasRenderingContext.imageSmoothingEnabled = true
        this.canvasRenderingContext.imageSmoothingQuality = 'medium'
        this.loadAnimation()
        this.hasLoaded = true
      })
  }

  private loadAnimation() {
    const canvasContext = this.canvasRenderingContext
    const jsonImage = this.image.jsonImage
    const jsonPath = this.image.jsonPath
    const prefix = this.imagePrefix
    const loop = this.isLooper

    this.animateItem = lottie.loadAnimation({
      name: jsonPath,
      container: canvasContext,
      renderer: 'canvas',
      loop: loop,
      autoplay: false,
      contentMode: 'Contain',
      path: jsonPath,
      imageAssetDelegate: (imagePath: string, callback: AsyncCallback<PixelMap>) => {
        let inc = false
        let imageCache: string = ''
        const imageArray = jsonImage
        for (let i = 0; i < imageArray.length; i++) {
          const imageArrayCache = imageArray[i]
          inc = imagePath.includes(imageArrayCache)
          if (inc) {
            imageCache = imageArrayCache
            break
          }
        }
        if (!inc) {
          callback(null, null)
          return
        }
        imagePath = prefix + imageCache
        this.getUIContext()?.getHostContext()?.resourceManager?.getRawFileContent(imagePath,
          (err, raw_file_content) => {
            if (this.isDestroyed) {
              callback(err, null)
              return
            }
            try {
              if (!err && raw_file_content) {
                let image_source = image.createImageSource(raw_file_content.buffer)
                image_source.createPixelMap({
                  editable: false,
                  desiredPixelFormat: image.PixelMapFormat.RGBA_8888
                }, callback)
                image_source.release()
              } else {
                //加载依赖图片异常时,默认动画加载正常
                callback(err, null)
              }
            } catch (e) {
              callback(err, null)
            }
          })
      }
    })
    // 因为动画是异步加载,所以对animateItem的操作需要放在动画加载完成回调里操作
    this.animateItem.addEventListener('DOMLoaded', this.onDomLoaded)
  }

  private onDomLoaded = () => {
    this.animateItem?.play()
  }

  private destroy() {
    this.isDestroyed = true
    if (this.animateItem) {
      this.animateItem.removeEventListener('DOMLoaded', this.onDomLoaded)
      this.animateItem.destroy()
      this.animateItem = null
    }
    this.hasLoaded = false
  }
}

3️⃣ 实际页面调用方式

复制代码
@Route({ name: RouterConst.PAGE_DEMO_JSON_ANIM })
@Preview
@ComponentV2
export struct PAGE_DEMO_JSON_ANIM {
  build() {
    ComNavDestination({}) {
      Column() {
        Row() {
          JsonAnimView({
            image: JsonConst.FetalConnect
          })
            .width(200)
            .height(200)
        }
      }
      .backgroundColor(ComColors.color_gray)
      .width(Percent.percent_100)
      .height(Percent.percent_100)
    }
  }
}

六、最后总结几个关键点

✔ JSON 一定放在 entry 的 ets 目录下

✔ 有图片资源就一定要用 imageAssetDelegate

✔ rawfile 加载图片是目前最稳的方案

✔ 动画是异步加载,play() 一定要放在 DOMLoaded 之后

✔ 页面销毁时记得 lottie.destroy(),否则内存会涨

相关推荐
麟听科技12 小时前
HarmonyOS 6.0+ APP智能种植监测系统开发实战:农业传感器联动与AI种植指导落地
人工智能·分布式·学习·华为·harmonyos
前端不太难13 小时前
HarmonyOS PC 焦点系统重建
华为·状态模式·harmonyos
空白诗13 小时前
基础入门 Flutter for Harmony:Text 组件详解
javascript·flutter·harmonyos
lbb 小魔仙14 小时前
【HarmonyOS】React Native实战+Popover内容自适应
react native·华为·harmonyos
左手厨刀右手茼蒿15 小时前
Flutter for OpenHarmony 实战:Barcode — 纯 Dart 条形码与二维码生成全指南
android·flutter·ui·华为·harmonyos
lbb 小魔仙15 小时前
【HarmonyOS】React Native of HarmonyOS实战:手势组合与协同
react native·华为·harmonyos
果粒蹬i16 小时前
【HarmonyOS】React Native实战项目+NativeStack原生导航
react native·华为·harmonyos
waeng_luo16 小时前
HarmonyOS 应用开发 Skills
华为·harmonyos
石去皿16 小时前
分布式原生:鸿蒙架构哲学与操作系统演进的范式转移
分布式·架构·harmonyos