鸿蒙PC实战:libwebp + libqrencode 二方库 NAPI 桥接 — 特效渲染与动图导出全流程
欢迎加入【开源鸿蒙PC社区】,一起共建鸿蒙化C/C++三方库生态。
欢迎在【PC社区】平台贡献你的项目。
仓库: unisources/TypeFun — 鸿蒙PC文字特效编辑器
集成平台: 鸿蒙PC | 测试SDK: 6.1.1(24) | DevEco Studio 6.1

一、前置说明
| 项目 | 说明 |
|---|---|
| 集成库 | Cairo 1.17.8 (.so) / fontconfig 2.14.2 (.a) / libpng 1.6.39 (.a) |
| 目标平台 | 鸿蒙PC |
| SDK 版本 | HarmonyOS SDK 6.1.1(24) — API 12 |
| 开发工具 | DevEco Studio 6.1 |
| 交叉编译工具链 | lycium_plusplus (BiSheng 编译器) |
| 前置依赖 | zlib 1.2.13 / bzip2 / brotli / libexpat 1.8.10 / pixman 0.42.2 (周期1) + freetype2 25.0.19 / harfbuzz 6.7.10 (周期2) |
| 项目地址 | https://atomgit.com/unisources/TypeFun |
一、要做什么?
TypeFun 的进一步目标是为应用添加三大能力:
- 文字特效渲染 — 发光(glow)、长阴影(shadow)、描边(stroke)三种特效
- WebP 静图/动图导出 — Cairo 画布 → WebP 编码 → 本地文件
- 二维码生成 — 输入文本 → libqrencode 生成 RGBA 像素 → 绘制到 Cairo 画布 → PNG 导出
这三个能力分别对应 libwebp 和 libqrencode 两个库:
| 库 | 用途 | 链接方式 | NAPI 函数 |
|---|---|---|---|
| libwebp | WebP 静图编码 | .so 动态链接 |
canvas.exportWebP |
| libwebp(libwebpmux) | WebP 动画编码 | .so 动态链接 |
canvas.exportAnimWebP |
| libqrencode | QR 二维码生成 | .a 静态链接 |
qr.generate |
此外,canvas.drawEffect(特效渲染)和 canvas.drawPixels(RGBA 像素绘制到 Cairo 画布)虽然不直接调用这两个库,但它们是完整功能链路中的关键环节。
整体调用链路:
┌─────────────────── ArkTS 层 ───────────────────┐
│ │
│ testEffect() testExportWebP() │
│ testQR() testAnimWebP() │
│ │ │ │
└───────┼──────────────────────┼────────────────────┘
│ NAPI │ NAPI
┌───────▼──────────────────────▼────────────────────┐
│ libentry.so (NAPI 桥接层) │
│ │
│ canvas.drawEffect ← Cairo + FreeType │
│ canvas.exportWebP ← libwebp/encode.h │
│ canvas.exportAnimWebP ← libwebp/mux.h │
│ canvas.drawPixels ← Cairo (RGBA→ARGB32 转换) │
│ qr.generate ← libqrencode │
└────────────────────────────────────────────────────┘
二、CMake 配置 — 引入 libwebp 和 libqrencode
2.1 库路径宏
TypeFun 项目已通过 lycium_plusplus 完成交叉编译,产物放在 thirdparty/<libname>/arm64-v8a/ 下。CMake 用 setup_lib 宏统一管理:
# CMakeLists.txt
set(THIRDPARTY_ROOT ${NATIVERENDER_ROOT_PATH}/thirdparty)
macro(setup_lib LIB_NAME)
set(${LIB_NAME}_ROOT ${THIRDPARTY_ROOT}/${LIB_NAME}/${OHOS_ARCH})
endmacro()
# 周期5 — 特效与动图导出
setup_lib(libwebp) # .so 链接
setup_lib(libqrencode) # .a 链接
宏展开后,${libwebp_ROOT} → thirdparty/libwebp/arm64-v8a,${libqrencode_ROOT} → thirdparty/libqrencode/arm64-v8a。
2.2 头文件路径
include_directories(
# ... 其他库 ...
# 周期5 — 特效与动图导出
${libwebp_ROOT}/include
${libqrencode_ROOT}/include
)
libwebp 的头文件结构为 webp/encode.h 和 webp/mux.h,libqrencode 为 qrencode.h。
2.3 链接方式
target_link_libraries(entry PUBLIC
libace_napi.z.so
# ... 其他库 ...
# 周期5 — 特效与动图导出
${libwebp_ROOT}/lib/libwebp.so # WebP 编码(动态库)
${libwebp_ROOT}/lib/libwebpmux.so # WebP 动画复用(动态库)
${libqrencode_ROOT}/lib/libqrencode.a # QR 编码(静态库)
)
链接方式决策:
| 库 | 方式 | 原因 |
|---|---|---|
| libwebp | .so 动态 |
编码器体积较大(~1.5MB),动态链接可被多模块共享;需放入 entry/libs/arm64-v8a/ |
| libwebpmux | .so 动态 |
动画编码依赖 libwebp,与 libwebp 一起动态分发 |
| libqrencode | .a 静态 |
体积极小(~50KB),静态编入 libentry.so,运行时无需额外分发 |
注意:
.so文件必须放入entry/libs/arm64-v8a/目录,否则运行时会报dlopen: library "libwebp.so" not found。.a文件在编译时链入,无需额外分发。
三、NAPI 桥接 — canvas 命名空间扩展
周期5 在已有的 canvas 命名空间中新增 3 个函数,并新增独立的 qr 命名空间:
canvas.drawEffect(canvasId, effect, params) → void
canvas.exportWebP(canvasId, path, quality?) → void
canvas.exportAnimWebP(canvasId, framePaths[], delays[], outputPath) → void
qr.generate(text, size?) → { width, height, data: number[] }
另外新增 canvas.drawPixels 作为辅助函数(解决 RGBA 像素无法直接写入 Cairo 画布的问题):
canvas.drawPixels(canvasId, srcWidth, srcHeight, data, dstX?, dstY?) → void
3.1 命名空间注册
在 canvas_bridge.cpp 的 CreateCanvasNamespace 工厂函数中注册周期5 的函数:
// canvas_bridge.cpp
napi_value CreateCanvasNamespace(napi_env env)
{
napi_value ns;
napi_create_object(env, &ns);
napi_property_descriptor desc[] = {
// ... 周期1-3 已有函数 ...
{"create", nullptr, CanvasCreate, nullptr, nullptr, nullptr, napi_default, nullptr},
{"drawGlyphs", nullptr, CanvasDrawGlyphs, nullptr, nullptr, nullptr, napi_default, nullptr},
{"exportPNG", nullptr, CanvasExportPNG, nullptr, nullptr, nullptr, napi_default, nullptr},
// ...
// 周期5 — 特效与动图导出
{"drawEffect", nullptr, CanvasDrawEffect, nullptr, nullptr, nullptr, napi_default, nullptr},
{"drawPixels", nullptr, CanvasDrawPixels, nullptr, nullptr, nullptr, napi_default, nullptr},
{"exportWebP", nullptr, CanvasExportWebP, nullptr, nullptr, nullptr, napi_default, nullptr},
{"exportAnimWebP", nullptr, CanvasExportAnimWebP, nullptr, nullptr, nullptr, napi_default, nullptr},
};
napi_define_properties(env, ns, sizeof(desc) / sizeof(desc[0]), desc);
return ns;
}
在 qr_bridge.cpp 中创建独立的 qr 命名空间:
// qr_bridge.cpp
napi_value CreateQRNamespace(napi_env env)
{
napi_value ns;
napi_create_object(env, &ns);
napi_property_descriptor desc[] = {
{"generate", nullptr, QRGenerate, nullptr, nullptr, nullptr, napi_default, nullptr},
};
napi_define_properties(env, ns, sizeof(desc) / sizeof(desc[0]), desc);
return ns;
}
在 napi_init.cpp 的 Init 中注册 qr 命名空间:
// napi_init.cpp
static napi_value Init(napi_env env, napi_value exports)
{
// ... 已有注册 ...
// 周期5: qr 命名空间
napi_value qrNs = CreateQRNamespace(env);
napi_set_named_property(env, exports, "qr", qrNs);
return exports;
}
3.2 版本验证
在 napi_init.cpp 的 VerifyLibs 中加入 libwebp 和 libqrencode 的版本号验证:
// napi_init.cpp — VerifyLibs 函数中
// 周期5
{
int ver = WebPGetEncoderVersion();
char buf[32];
snprintf(buf, sizeof(buf), "%d.%d.%d", (ver >> 16) & 0xFF, (ver >> 8) & 0xFF, ver & 0xFF);
setProp("libwebp", buf);
}
{
int major, minor, micro;
QRcode_APIVersion(&major, &minor, µ);
char buf[32];
snprintf(buf, sizeof(buf), "%d.%d.%d", major, minor, micro);
setProp("libqrencode", buf);
}
ArkTS 侧调用 testNapi.verifyLibs() 即可确认两个库是否正确链接:
const versions = testNapi.verifyLibs();
// versions.libwebp → "1.4.0"
// versions.libqrencode → "4.1.1"
四、libwebp 桥接详解 — WebP 静图与动图导出
4.1 头文件引入
libwebp 需要引入两个头文件:encode.h(编码)和 mux.h(动画复用):
// canvas_bridge.cpp 顶部
#include "thirdparty/libwebp/arm64-v8a/include/webp/encode.h"
#include "thirdparty/libwebp/arm64-v8a/include/webp/mux.h"
4.2 canvas.exportWebP — 静图导出
这是最核心的函数:从 Cairo ARGB32 画布取出像素 → 转 RGBA → WebP 编码 → 写入文件。
4.2.1 函数签名
// canvas.exportWebP(canvasId, path, quality?): void
// - canvasId: 画布 ID
// - path: 输出文件路径
// - quality: 压缩质量 1-100(默认 80)
4.2.2 完整实现
static napi_value CanvasExportWebP(napi_env env, napi_callback_info info)
{
size_t argc = 3;
napi_value args[3];
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
if (argc < 2) {
napi_throw_error(env, nullptr, "需要 canvasId 和 path 两个参数");
return nullptr;
}
// ── 参数解析 ──
int32_t canvasId;
napi_get_value_int32(env, args[0], &canvasId);
if (canvasId < 0 || canvasId >= static_cast<int32_t>(g_canvases.size())
|| !g_canvases[canvasId].cr) {
napi_throw_error(env, nullptr, "无效的 canvasId");
return nullptr;
}
CanvasEntry &canvas = g_canvases[canvasId];
char filePath[1024];
size_t pathLen;
napi_get_value_string_utf8(env, args[1], filePath, sizeof(filePath), &pathLen);
float quality = 80.0f;
if (argc >= 3) {
napi_valuetype t;
napi_typeof(env, args[2], &t);
if (t == napi_number) {
double q;
napi_get_value_double(env, args[2], &q);
quality = static_cast<float>(q);
}
}
if (quality < 1) quality = 1;
if (quality > 100) quality = 100;
// ── 第一步:从 Cairo surface 获取像素数据 ──
cairo_surface_flush(canvas.surface);
unsigned char *srcData = cairo_image_surface_get_data(canvas.surface);
int width = canvas.width;
int height = canvas.height;
int stride = cairo_image_surface_get_stride(canvas.surface);
// ── 第二步:Cairo ARGB32 → RGBA 转换 ──
// Cairo 预乘 ARGB: src[0]=B, src[1]=G, src[2]=R, src[3]=A
std::vector<uint8_t> rgbaData(width * height * 4);
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
uint8_t *src = srcData + y * stride + x * 4;
uint8_t *dst = rgbaData.data() + (y * width + x) * 4;
uint8_t a = src[3];
if (a > 0) {
dst[0] = (src[2] * 255 + a / 2) / a; // R (反预乘)
dst[1] = (src[1] * 255 + a / 2) / a; // G
dst[2] = (src[0] * 255 + a / 2) / a; // B
} else {
dst[0] = dst[1] = dst[2] = 0;
}
dst[3] = a; // A
}
}
// ── 第三步:WebP 编码 ──
WebPConfig config;
if (!WebPConfigPreset(&config, WEBP_PRESET_DEFAULT, quality)) {
napi_throw_error(env, nullptr, "WebP 配置初始化失败");
return nullptr;
}
WebPPicture picture;
if (!WebPPictureInit(&picture)) {
napi_throw_error(env, nullptr, "WebPPicture 初始化失败");
return nullptr;
}
picture.width = width;
picture.height = height;
picture.use_argb = 1;
if (!WebPPictureImportRGBA(&picture, rgbaData.data(), width * 4)) {
napi_throw_error(env, nullptr, "WebP 像素导入失败");
WebPPictureFree(&picture);
return nullptr;
}
WebPMemoryWriter writer;
WebPMemoryWriterInit(&writer);
picture.writer = WebPMemoryWrite;
picture.custom_ptr = &writer;
if (!WebPEncode(&config, &picture)) {
char errMsg[128];
snprintf(errMsg, sizeof(errMsg), "WebP 编码失败: error code %d", picture.error_code);
napi_throw_error(env, nullptr, errMsg);
WebPMemoryWriterClear(&writer);
WebPPictureFree(&picture);
return nullptr;
}
// ── 第四步:写入文件 ──
FILE *fp = fopen(filePath, "wb");
if (fp) {
fwrite(writer.mem, 1, writer.size, fp);
fclose(fp);
} else {
napi_throw_error(env, nullptr, "无法写入 WebP 文件");
WebPMemoryWriterClear(&writer);
WebPPictureFree(&picture);
return nullptr;
}
WebPMemoryWriterClear(&writer);
WebPPictureFree(&picture);
fprintf(stdout, "[TypeFun] WebP 已导出: %dx%d quality=%.0f path=%s\n",
width, height, quality, filePath);
napi_value result;
napi_get_undefined(env, &result);
return result;
}
4.2.3 像素格式转换详解(关键坑点)
这是整个 exportWebP 最容易出错的地方。Cairo 和 WebP 的像素格式完全不同:
| 偏移 | Cairo ARGB32(预乘) | WebP RGBA(标准) |
|---|---|---|
[0] |
B(已预乘 alpha) | R |
[1] |
G(已预乘 alpha) | G |
[2] |
R(已预乘 alpha) | B |
[3] |
A | A |
转换需要两步:反预乘 + 通道重排。
反预乘公式(以 R 通道为例):
R_standard = (R_premultiplied * 255 + alpha / 2) / alpha
+ alpha / 2 是四舍五入,避免精度损失。当 alpha = 0 时,RGB 均置为 0。
如果直接把 Cairo 像素传给 WebPPictureImportRGBA,结果会是颜色偏蓝、半透明区域发暗,因为 R/B 通道互换了,且预乘值被当作标准值处理。
4.3 canvas.exportAnimWebP — 动画导出
动画 WebP 的编码流程:多帧 PNG 图片 → 逐帧读取 → ARGB32→RGBA 转换 → WebPAnimEncoder 合成。
4.3.1 函数签名
// canvas.exportAnimWebP(canvasId, framePaths[], delays[], outputPath): void
// - canvasId: 画布 ID(用于获取画布尺寸作为参考)
// - framePaths: PNG 文件路径数组
// - delays: 每帧延迟时间数组(毫秒)
// - outputPath: 输出动画 WebP 文件路径
4.3.2 核心实现
static napi_value CanvasExportAnimWebP(napi_env env, napi_callback_info info)
{
// ... 参数解析(canvasId, framePaths[], delays[], outputPath)...
CanvasEntry &canvas = g_canvases[canvasId];
int canvasW = canvas.width;
int canvasH = canvas.height;
// 创建动画编码器
WebPAnimEncoderOptions encOptions;
WebPAnimEncoderOptionsInit(&encOptions);
WebPAnimEncoder *animEnc = WebPAnimEncoderNew(canvasW, canvasH, &encOptions);
if (!animEnc) {
napi_throw_error(env, nullptr, "WebP 动画编码器创建失败");
return nullptr;
}
int timestamp_ms = 0;
bool hasError = false;
// 逐帧处理
for (uint32_t i = 0; i < frameCount && !hasError; i++) {
// 读取帧路径和延迟
// ...
// 用 Cairo 读取 PNG(复用 Cairo 的 PNG 解码能力)
cairo_surface_t *frameSurf = cairo_image_surface_create_from_png(framePath);
if (cairo_surface_status(frameSurf) != CAIRO_STATUS_SUCCESS) {
// 错误处理
hasError = true;
break;
}
int fw = cairo_image_surface_get_width(frameSurf);
int fh = cairo_image_surface_get_height(frameSurf);
int fstride = cairo_image_surface_get_stride(frameSurf);
unsigned char *fdata = cairo_image_surface_get_data(frameSurf);
cairo_surface_flush(frameSurf);
// ARGB32 → RGBA 转换(与 exportWebP 相同逻辑)
std::vector<uint8_t> rgbaFrame(fw * fh * 4);
for (int y = 0; y < fh; y++) {
for (int x = 0; x < fw; x++) {
uint8_t *src = fdata + y * fstride + x * 4;
uint8_t *dst = rgbaFrame.data() + (y * fw + x) * 4;
uint8_t a = src[3];
if (a > 0) {
dst[0] = (src[2] * 255 + a / 2) / a; // R
dst[1] = (src[1] * 255 + a / 2) / a; // G
dst[2] = (src[0] * 255 + a / 2) / a; // B
} else {
dst[0] = dst[1] = dst[2] = 0;
}
dst[3] = a;
}
}
cairo_surface_destroy(frameSurf);
// 导入到 WebPPicture
WebPPicture frame;
WebPPictureInit(&frame);
frame.width = fw;
frame.height = fh;
frame.use_argb = 1;
if (!WebPPictureImportRGBA(&frame, rgbaFrame.data(), fw * 4)) {
hasError = true;
break;
}
// 添加帧到动画编码器
WebPConfig frameConfig;
WebPConfigPreset(&frameConfig, WEBP_PRESET_DEFAULT, 75.0f);
if (!WebPAnimEncoderAdd(animEnc, &frame, timestamp_ms, &frameConfig)) {
hasError = true;
WebPPictureFree(&frame);
break;
}
WebPPictureFree(&frame);
timestamp_ms += delayMs;
}
// 结束编码
if (!hasError) {
WebPAnimEncoderAdd(animEnc, nullptr, timestamp_ms, nullptr); // 空帧收尾
}
// 组装并写入文件
WebPData webpData;
WebPDataInit(&webpData);
if (!hasError && WebPAnimEncoderAssemble(animEnc, &webpData)) {
FILE *fp = fopen(outputPath, "wb");
if (fp) {
fwrite(webpData.bytes, 1, webpData.size, fp);
fclose(fp);
}
}
WebPDataClear(&webpData);
WebPAnimEncoderDelete(animEnc);
napi_value result;
napi_get_undefined(env, &result);
return result;
}
4.3.3 动画编码关键点
| 要点 | 说明 |
|---|---|
WebPAnimEncoder |
来自 webp/mux.h,需要链接 libwebpmux.so |
timestamp_ms |
每帧的时间戳(毫秒),由 delays[] 数组累加 |
| 空帧收尾 | WebPAnimEncoderAdd(nullptr, ...) 通知编码器所有帧已添加 |
| PNG 读取 | 复用 Cairo 的 cairo_image_surface_create_from_png,避免再引入 libpng API |
| 每帧独立转换 | 每帧都需要 ARGB32→RGBA 转换,因为 Cairo 读取 PNG 后也是 ARGB32 格式 |
五、libqrencode 桥接详解 — 二维码生成与绘制
5.1 头文件引入
// qr_bridge.cpp
#include "thirdparty/libqrencode/arm64-v8a/include/qrencode.h"
5.2 qr.generate — 生成二维码像素数据
5.2.1 函数签名
// qr.generate(text: string, size?: number): { width, height, data: number[] }
// - text: 要编码的文本
// - size: 输出像素尺寸(默认 256,范围 64-2048)
// - 返回: RGBA 像素数组(width × height × 4)
5.2.2 完整实现
static napi_value QRGenerate(napi_env env, napi_callback_info info)
{
size_t argc = 2;
napi_value args[2];
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
if (argc < 1) {
napi_throw_error(env, nullptr, "需要 text 参数");
return nullptr;
}
// ── 参数 1: text ──
size_t textLen = 0;
napi_get_value_string_utf8(env, args[0], nullptr, 0, &textLen);
std::vector<char> textBuf(textLen + 1);
napi_get_value_string_utf8(env, args[0], textBuf.data(), textLen + 1, &textLen);
// ── 参数 2: size(可选,默认 256) ──
int32_t outSize = 256;
if (argc >= 2) {
napi_valuetype t;
napi_typeof(env, args[1], &t);
if (t == napi_number) {
napi_get_value_int32(env, args[1], &outSize);
}
}
if (outSize < 64) outSize = 64;
if (outSize > 2048) outSize = 2048;
// ── 生成二维码 ──
QRcode *qrcode = QRcode_encodeString(textBuf.data(), 0,
QR_ECLEVEL_M, QR_MODE_8, 1);
if (!qrcode) {
napi_throw_error(env, nullptr, "二维码生成失败:输入文本可能过长或不支持");
return nullptr;
}
int modules = qrcode->width; // QR 码模块数(含 quiet zone)
int pixelSize = outSize / modules; // 每个模块的像素数
if (pixelSize < 1) pixelSize = 1;
int imgSize = modules * pixelSize;
// ── 构建 RGBA 像素数组 ──
std::vector<uint8_t> pixels(imgSize * imgSize * 4, 255); // 白色背景
for (int row = 0; row < modules; row++) {
for (int col = 0; col < modules; col++) {
bool isDark = qrcode->data[row * modules + col] & 0x01;
if (isDark) {
// 填充黑色模块(pixelSize × pixelSize 像素块)
for (int py = 0; py < pixelSize; py++) {
for (int px = 0; px < pixelSize; px++) {
int idx = ((row * pixelSize + py) * imgSize
+ (col * pixelSize + px)) * 4;
pixels[idx + 0] = 0; // R
pixels[idx + 1] = 0; // G
pixels[idx + 2] = 0; // B
pixels[idx + 3] = 255; // A
}
}
}
}
}
QRcode_free(qrcode);
// ── 构建返回值 ──
napi_value result;
napi_create_object(env, &result);
SetPropI32(env, result, "width", imgSize);
SetPropI32(env, result, "height", imgSize);
napi_value dataArr;
napi_create_array_with_length(env, pixels.size(), &dataArr);
for (size_t i = 0; i < pixels.size(); i++) {
napi_value v;
napi_create_int32(env, pixels[i], &v);
napi_set_element(env, dataArr, i, v);
}
napi_set_named_property(env, result, "data", dataArr);
return result;
}
5.2.3 QR 编码参数说明
| 参数 | 值 | 说明 |
|---|---|---|
version |
0 |
自动选择最小版本 |
QR_ECLEVEL_M |
中等纠错 | 约 15% 容错率 |
QR_MODE_8 |
8-bit 模式 | 支持 UTF-8 中文 |
casesensitive |
1 |
区分大小写 |
5.3 canvas.drawPixels — RGBA 像素绘制到 Cairo 画布
这是解决二维码渲染问题的关键函数。qr.generate 返回的是标准 RGBA 像素,但 Cairo 画布使用 ARGB32 格式,无法直接写入。drawPixels 完成了 RGBA → ARGB32 预乘的转换。
5.3.1 函数签名
// canvas.drawPixels(canvasId, srcWidth, srcHeight, data, dstX?, dstY?): void
// - canvasId: 目标画布 ID
// - srcWidth, srcHeight: 像素数据尺寸
// - data: number[] (RGBA 像素数组)
// - dstX, dstY: 目标位置(默认 0,0)
5.3.2 核心转换逻辑
// 读取 RGBA 数据
std::vector<uint8_t> rgbaData(dataLen);
for (uint32_t i = 0; i < dataLen; i++) {
napi_value v;
napi_get_element(env, args[3], i, &v);
int32_t val;
napi_get_value_int32(env, v, &val);
rgbaData[i] = static_cast<uint8_t>(val);
}
// 创建临时 ARGB32 表面
cairo_surface_t *tmpSurf = cairo_image_surface_create(
CAIRO_FORMAT_ARGB32, srcWidth, srcHeight);
unsigned char *dst = cairo_image_surface_get_data(tmpSurf);
int stride = cairo_image_surface_get_stride(tmpSurf);
// RGBA → Cairo ARGB32 (预乘 BGR-A)
for (int y = 0; y < srcHeight; y++) {
for (int x = 0; x < srcWidth; x++) {
int srcIdx = (y * srcWidth + x) * 4;
uint8_t r = rgbaData[srcIdx + 0];
uint8_t g = rgbaData[srcIdx + 1];
uint8_t b = rgbaData[srcIdx + 2];
uint8_t a = rgbaData[srcIdx + 3];
double af = a / 255.0;
int dstIdx = y * stride + x * 4;
dst[dstIdx + 0] = static_cast<uint8_t>(b * af + 0.5); // B pre-multiplied
dst[dstIdx + 1] = static_cast<uint8_t>(g * af + 0.5); // G pre-multiplied
dst[dstIdx + 2] = static_cast<uint8_t>(r * af + 0.5); // R pre-multiplied
dst[dstIdx + 3] = a; // A
}
}
cairo_surface_mark_dirty(tmpSurf);
// 将临时表面绘制到画布
cairo_save(canvas.cr);
cairo_set_source_surface(canvas.cr, tmpSurf, dstX, dstY);
cairo_rectangle(canvas.cr, dstX, dstY, srcWidth, srcHeight);
cairo_clip(canvas.cr);
cairo_paint(canvas.cr);
cairo_restore(canvas.cr);
cairo_surface_destroy(tmpSurf);
5.3.3 为什么要先写入临时表面?
不能直接修改画布的像素数据,因为:
- 画布可能正在进行其他绘制操作
cairo_surface_mark_dirty需要标记脏区域以触发重绘- 使用
cairo_set_source_surface+cairo_paint可以正确处理 alpha 混合
流程:RGBA 数据 → 临时 ARGB32 表面 → cairo_paint 合成到目标画布。
六、特效渲染 — canvas.drawEffect
drawEffect 不直接调用 libwebp 或 libqrencode,但它是周期5 功能链路的一部分(特效渲染后通常需要导出为 WebP)。
6.1 函数签名
// canvas.drawEffect(canvasId, effect, params): void
// - effect: 'glow' | 'shadow' | 'stroke'
// - params: { fontId, fontSize, color, x, y, text, glowColor?, glowRadius?, strokeColor?, strokeWidth? }
6.2 三种特效实现
发光(glow)
多层渐变透明度绘制,从外到内透明度递增:
if (strcmp(effect, "glow") == 0) {
// 绘制发光层(由外到内,透明度递增)
for (int i = glowRadius; i > 0; i--) {
cairo_save(canvas.cr);
cairo_set_font_face(canvas.cr, cairoFace);
cairo_set_font_size(canvas.cr, fontSize);
double alpha = 0.15 * (1.0 - (double)i / glowRadius);
cairo_set_source_rgba(canvas.cr, gr, gg, gb, alpha);
cairo_move_to(canvas.cr, offsetX + i * 0.5, offsetY + i * 0.5);
cairo_show_text(canvas.cr, textBuf);
cairo_restore(canvas.cr);
}
// 绘制正文
cairo_set_font_face(canvas.cr, cairoFace);
cairo_set_font_size(canvas.cr, fontSize);
cairo_set_source_rgba(canvas.cr, r, g, b, a);
cairo_move_to(canvas.cr, offsetX, offsetY);
cairo_show_text(canvas.cr, textBuf);
}
长阴影(shadow)
逐层偏移绘制黑色半透明文字:
else if (strcmp(effect, "shadow") == 0) {
for (int i = static_cast<int>(shadowLen); i > 0; i--) {
double alpha = 0.15 * (1.0 - (double)i / shadowLen);
cairo_save(canvas.cr);
cairo_set_font_face(canvas.cr, cairoFace);
cairo_set_font_size(canvas.cr, fontSize);
cairo_set_source_rgba(canvas.cr, 0, 0, 0, alpha);
cairo_move_to(canvas.cr, offsetX + i, offsetY + i);
cairo_show_text(canvas.cr, textBuf);
cairo_restore(canvas.cr);
}
// 正文
cairo_set_source_rgba(canvas.cr, r, g, b, a);
cairo_move_to(canvas.cr, offsetX, offsetY);
cairo_show_text(canvas.cr, textBuf);
}
描边(stroke)
先 cairo_text_path + cairo_stroke 画描边,再 cairo_show_text 填充:
else if (strcmp(effect, "stroke") == 0) {
// 描边
cairo_set_font_face(canvas.cr, cairoFace);
cairo_set_font_size(canvas.cr, fontSize);
cairo_set_source_rgba(canvas.cr, sr, sg, sb, sa);
cairo_set_line_width(canvas.cr, strokeWidth);
cairo_move_to(canvas.cr, offsetX, offsetY);
cairo_text_path(canvas.cr, textBuf);
cairo_stroke(canvas.cr);
// 填充正文
cairo_set_source_rgba(canvas.cr, r, g, b, a);
cairo_move_to(canvas.cr, offsetX, offsetY);
cairo_show_text(canvas.cr, textBuf);
}
6.3 ArkTS 调用示例
testEffect(): void {
const fontId = this.getLatestFontId();
const cId = testNapi.canvas.create(600, 200);
// 发光特效
testNapi.canvas.drawEffect(cId, 'glow', {
fontId: fontId,
fontSize: 64,
color: '#FFFFFF',
x: 50,
y: 120,
text: '你好鸿蒙',
glowColor: '#FFD700',
glowRadius: 8,
});
// 导出为 PNG 预览(也可改为 exportWebP)
const effectPath = this.exportCanvasAsPng(cId, 'effect_glow.png');
}
七、ArkTS 类型声明
在 Index.d.ts 中添加周期5 的类型声明:
// ─── 周期5: 特效与动图导出 ─────────────────────────────
/** 特效绘制参数 */
export interface EffectParams {
fontId: number;
fontSize: number;
color: string;
x?: number;
y?: number;
text: string;
glowColor?: string;
glowRadius?: number;
strokeColor?: string;
strokeWidth?: number;
}
/** 二维码生成结果 */
export interface QRResult {
width: number;
height: number;
data: number[];
}
/** 渲染画布命名空间(周期5 扩展) */
export const canvas: {
// ... 已有函数 ...
/** 将 RGBA 像素数组绘制到画布 */
drawPixels(canvasId: number, srcWidth: number, srcHeight: number,
data: number[], dstX?: number, dstY?: number): void;
/** 绘制特效文字(glow / shadow / stroke) */
drawEffect(canvasId: number, effect: string, params: EffectParams): void;
/** 导出画布为 WebP 文件(quality: 0-100,默认 75) */
exportWebP(canvasId: number, path: string, quality?: number): void;
/** 将多帧 PNG 合成动画 WebP */
exportAnimWebP(canvasId: number, framePaths: string[],
delays: number[], outputPath: string): void;
};
/** 二维码命名空间 */
export const qr: {
/** 生成二维码,返回 RGBA 像素数据(size: 输出像素尺寸,默认 256) */
generate(text: string, size?: number): QRResult;
};
八、ArkTS 调用层 — 完整功能串联
8.1 WebP 导出
testExportWebP(): void {
try {
const fontId = this.getLatestFontId();
// 先用已有方法创建带文字的画布
const cId = this.createCanvasWithText('你好鸿蒙', fontId, 48, '#E53935', 50, 120, 'zh');
// 导出为 WebP
const ctx = getContext(this);
const webpPath = ctx.filesDir + '/test_export.webp';
testNapi.canvas.exportWebP(cId, webpPath, 80);
this.webpInfo += `✅ WebP 已导出\n 路径: ${webpPath}\n`;
} catch (e) {
this.webpInfo = '❌ WebP导出失败: ' + (e as Error).message;
}
}
8.2 二维码生成 + 绘制到画布
testQR(): void {
try {
// 第一步:生成二维码像素数据
const result = testNapi.qr.generate('你好鸿蒙 TypeFun!', 256);
this.qrInfo += `✅ 二维码已生成: ${result.width}x${result.height}px\n`;
// 第二步:创建画布,将 QR 像素绘制到画布
const cId = testNapi.canvas.create(result.width, result.height);
testNapi.canvas.drawPixels(cId, result.width, result.height, result.data);
// 第三步:可选叠加文字提示
const fontId = this.getLatestFontId();
if (fontId >= 0) {
this.drawTextLine(cId, 'QR: TypeFun', fontId, 16, '#999999', 10, 20, 'en');
}
// 第四步:导出为 PNG 预览
const qrPath = this.exportCanvasAsPng(cId, 'qr_test.png');
this.qrPngPath = qrPath;
} catch (e) {
this.qrInfo = '❌ 二维码生成失败: ' + (e as Error).message;
}
}
8.3 特效渲染
testEffect(): void {
try {
const fontId = this.getLatestFontId();
const cId = testNapi.canvas.create(600, 200);
// 发光特效
testNapi.canvas.drawEffect(cId, 'glow', {
fontId: fontId,
fontSize: 64,
color: '#FFFFFF',
x: 50,
y: 120,
text: '你好鸿蒙',
glowColor: '#FFD700',
glowRadius: 8,
});
const effectPath = this.exportCanvasAsPng(cId, 'effect_glow.png');
this.effectInfo += '✅ 发光特效已渲染并导出\n';
} catch (e) {
this.effectInfo = '❌ 特效测试失败: ' + (e as Error).message;
}
}
8.4 UI 区段
@Builder effectsSection() {
Column() {
this.sectionTitle('特效与导出')
this.subtitle('WebP 动图、二维码与发光特效')
Row({ space: 12 }) {
this.outlinePill('WebP 导出', () => this.testExportWebP())
this.outlinePill('二维码', () => this.testQR())
this.outlinePill('特效测试', () => this.testEffect())
}
if (this.webpInfo) this.dataText(this.webpInfo, 20)
if (this.qrInfo) this.dataText(this.qrInfo, 20)
if (this.effectInfo) this.dataText(this.effectInfo, 20)
// 展示二维码结果
if (this.qrPngPath) {
Column() {
Image('file://' + this.qrPngPath)
.width(160).height(160)
.objectFit(ImageFit.Contain)
.borderRadius(8)
}
.padding(20)
.backgroundColor('#1a2f4a')
.borderRadius(12)
}
// 展示渲染结果
if (this.pngPath) {
Image('file://' + this.pngPath)
.width('100%')
.aspectRatio(3)
.objectFit(ImageFit.Contain)
.borderRadius(8)
}
}
}
九、踩坑专区
坑 #1:RGBA↔ARGB32 像素格式 — 二维码渲染黑屏 / WebP 颜色偏蓝
现象:
- 调用
qr.generate()生成的 RGBA 像素直接写入 Cairo 画布 → 结果全黑 - Cairo 画布导出 WebP → 颜色偏蓝,半透明区域发暗
根因:Cairo 使用 CAIRO_FORMAT_ARGB32(预乘 BGR-A),而 libqrencode 输出标准 RGBA,WebP 也需要标准 RGBA 输入。三者的内存布局完全不同:
| 偏移 | Cairo ARGB32 | QR 输出 / WebP 输入 |
|---|---|---|
[0] |
B(预乘) | R |
[1] |
G(预乘) | G |
[2] |
R(预乘) | B |
[3] |
A | A |
修复:
- QR → Cairo:新增
canvas.drawPixels,内部做 RGBA→预乘ARGB32 转换后写入临时 Cairo 表面,再cairo_paint合成到目标画布 - Cairo → WebP:在
exportWebP和exportAnimWebP中,取出 Cairo 像素后做反预乘 + 通道重排,转回标准 RGBA 再传给WebPPictureImportRGBA
// RGBA → Cairo ARGB32 (drawPixels 中)
double af = a / 255.0;
dst[dstIdx + 0] = static_cast<uint8_t>(b * af + 0.5); // B pre-multiplied
dst[dstIdx + 1] = static_cast<uint8_t>(g * af + 0.5); // G pre-multiplied
dst[dstIdx + 2] = static_cast<uint8_t>(r * af + 0.5); // R pre-multiplied
dst[dstIdx + 3] = a;
// Cairo ARGB32 → RGBA (exportWebP 中,反预乘)
uint8_t a = src[3];
if (a > 0) {
dst[0] = (src[2] * 255 + a / 2) / a; // R (反预乘)
dst[1] = (src[1] * 255 + a / 2) / a; // G
dst[2] = (src[0] * 255 + a / 2) / a; // B
} else {
dst[0] = dst[1] = dst[2] = 0;
}
dst[3] = a;
ArkTS 侧对比:
testQR(): void {
const result = testNapi.qr.generate('你好鸿蒙 TypeFun!', 256);
- // 之前没有 drawPixels,二维码像素无处渲染 → 黑屏
+ const cId = testNapi.canvas.create(result.width, result.height);
+ testNapi.canvas.drawPixels(cId, result.width, result.height, result.data);
}
坑 #2:libwebp.so 运行时找不到
现象:编译通过,但运行时 dlopen: library "libwebp.so" not found。
根因:.so 动态库需要放入 entry/libs/arm64-v8a/ 目录,仅 CMake 链接不够。CMake 的 target_link_libraries 只解决编译时符号解析,运行时加载器需要能找到 .so 文件。
修复:
# 确认 .so 文件存在
ls entry/libs/arm64-v8a/
# libcairo.so libopencc.so libwebp.so libwebpmux.so libexpat.so ...
如果缺少,从 lycium_plusplus 产物中复制:
cp thirdparty/libwebp/arm64-v8a/lib/libwebp.so entry/libs/arm64-v8a/
cp thirdparty/libwebp/arm64-v8a/lib/libwebpmux.so entry/libs/arm64-v8a/
同时确认 module.json5 中未声明对 .so 的限制。
坑 #3:WebPPictureImportRGBA 编码失败
现象:WebPEncode 返回 false,picture.error_code 为 VP8_ENC_ERROR_PARAMETER。
根因:picture.use_argb 未设置为 1,或 WebPConfigPreset 的 quality 参数为 0。
修复:
picture.use_argb = 1; // 必须设置,否则低质量模式下会走有损 YUV 路径导致错误
if (quality < 1) quality = 1; // quality 不能为 0
if (quality > 100) quality = 100;
十、像素格式转换总结
Cairo ARGB32 与 WebP RGBA 之间的转换是周期5 最核心也最容易出错的部分。整理如下:
┌──────────────────────┐
│ Cairo ARGB32 画布 │
│ 预乘 BGR-A 格式 │
└──────┬───────┬────────┘
│ │
exportWebP │ │ drawPixels
(反预乘+重排) │ │ (预乘+重排)
│ │
▼ │
┌─────────────┐ │
│ 标准 RGBA │ │
│ (WebP/Q R) │ │
└─────────────┘ │
▲ │
WebPPictureImportRGBA │
│ │
┌──────┴───────┴────────┐
│ libwebp / libqrencode │
│ 输入输出均为标准 RGBA │
└────────────────────────┘
| 转换方向 | 函数 | 公式 |
|---|---|---|
RGBA → ARGB32(drawPixels) |
预乘 + R↔B 互换 | B_out = B × (A/255), R_out = R × (A/255), A_out = A |
ARGB32 → RGBA(exportWebP) |
反预乘 + R↔B 互换 | R_out = (R_premul × 255 + A/2) / A, A_out = A |
十一、通用集成模板
CMake 模板(周期5 库)
# ─── 宏 ───
set(THIRDPARTY_ROOT ${NATIVERENDER_ROOT_PATH}/thirdparty)
macro(setup_lib LIB_NAME)
set(${LIB_NAME}_ROOT ${THIRDPARTY_ROOT}/${LIB_NAME}/${OHOS_ARCH})
endmacro()
setup_lib(libwebp)
setup_lib(libqrencode)
# ─── 头文件 ───
include_directories(
${libwebp_ROOT}/include
${libqrencode_ROOT}/include
)
# ─── 链接 ───
target_link_libraries(entry PUBLIC
libace_napi.z.so
${libwebp_ROOT}/lib/libwebp.so # WebP 编码
${libwebp_ROOT}/lib/libwebpmux.so # WebP 动画
${libqrencode_ROOT}/lib/libqrencode.a # QR 编码
)
NAPI 桥接模板(WebP 导出)
#include "webp/encode.h"
#include "webp/mux.h"
static napi_value YourExportWebP(napi_env env, napi_callback_info info)
{
// 1. 参数解析 (canvasId, path, quality?)
// 2. 从 Cairo surface 获取像素 → ARGB32→RGBA 转换
// 3. WebPConfigPreset + WebPPictureInit + WebPPictureImportRGBA
// 4. WebPEncode → WebPMemoryWriter → 写文件
// 5. 清理: WebPMemoryWriterClear + WebPPictureFree
}
NAPI 桥接模板(二维码)
#include "qrencode.h"
static napi_value YourQRGenerate(napi_env env, napi_callback_info info)
{
// 1. 参数解析 (text, size?)
// 2. QRcode_encodeString → QRcode*
// 3. 遍历 modules → 构建 RGBA 像素数组
// 4. 构建返回值 { width, height, data: number[] }
// 5. QRcode_free
}
十二、总结
在鸿蒙PC应用中引入 libwebp 和 libqrencode,核心挑战不在编译链接(lycium_plusplus 已解决交叉编译),而在 NAPI 桥接 和 像素格式转换:
-
Cairo ARGB32 与标准 RGBA 的格式差异是最大隐蔽陷阱:QR 生成 → Cairo 绘制需要 RGBA→预乘ARGB32(
drawPixels),Cairo → WebP 导出需要反预乘ARGB32→RGBA(exportWebP)。两个方向的转换公式不同,一旦搞反就会黑屏或颜色偏蓝。 -
libwebp 动画编码器需要
libwebpmux.so:仅链接libwebp.so只能做静图编码,动画必须额外链接libwebpmux.so。 -
libqrencode 输出的是像素模块而非图像:需要自行将 QR 模块(0/1 矩阵)扩展为 RGBA 像素数组,再通过
drawPixels绘制到 Cairo 画布。 -
.so动态库需放入entry/libs/arm64-v8a/:CMake 链接只解决编译时符号,运行时加载器需要在应用包内找到.so文件。
更多推荐




所有评论(0)