在鸿蒙 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(),否则内存会涨