
HarmonyOS NEXT的Native UI开发中,一种常见的需求
HarmonyOS NEXT的ArkUI框架提供了丰富的Canvas API,能满足大部分2D图形绘制需求。但遇到对性能要求较高的复杂图形渲染场景------比如实时地理信息系统(GIS)地图渲染、复杂的数据可视化图表(如大量节点的拓扑图)、高精度矢量字体排版时,ArkUI的Canvas在某些场景下会成为瓶颈。
这个问题在HarmonyOS开发里比较常见。很多人第一次尝试在Native层绘制复杂图形时,会优先考虑OpenGL ES或Vulkan。但对于大多数2D图形渲染任务,Skia是一个更合适的选择------它提供了完整的2D图形管线、跨平台一致性、丰富的文字排版和路径操作能力,而且不需要像OpenGL那样管理复杂的着色器。
这篇实战教程会走通一个完整流程:将Skia引入HarmonyOS NDK项目、在C++层完成图形渲染、通过OHOS Native组件将渲染结果显示到ArkUI页面上。过程中会涉及库的编译配置、头文件路径设置、渲染上下文的桥接,以及一些实际项目中需要注意的性能问题。
它解决什么问题
适用场景
| 场景 | ArkUI Canvas | Skia + NDK |
|---|---|---|
| 简单2D图形(矩形、圆形、直线) | 简单直接 | 大材小用 |
| 复杂矢量图形(贝塞尔曲线、路径裁剪) | 性能受限,支持有限 | 原生支持,性能可控 |
| 大量文字排版(多语言、复杂排版) | 功能有限 | 完整排版引擎 |
| 实时动画/交互式绘图 | 有性能瓶颈 | 利用CPU/GPU渲染 |
| 跨平台代码复用 | 仅限鸿蒙 | 可复用其他平台 |
为什么不直接用OpenGL ES
OpenGL ES确实能提供最高的渲染性能,但它需要开发者自己处理更多的底层逻辑:顶点缓冲、着色器编译、帧缓冲区管理等。Skia对这些细节做了封装,对于2D图形渲染而言,用Skia的开发效率远高于OpenGL ES,且效果不差。如果你的目标是绘制2D图形而不是3D场景,Skia通常是更务实的选择。
为什么不直接用ArkUI Canvas
ArkUI Canvas在普通场景下完全够用。但当你需要渲染数千个独立的矢量元素时,ArkUI的组件化架构反而成了负担------每个路径都是一次UI组件更新。而在Skia的渲染流程里,所有图形操作都转换为绘制指令,最终一次性提交到GPU,性能差异很明显。
环境说明
text
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机、平板
核心实现:集成Skia到NDK项目
第一步:准备Skia库
需要做两件事:编译Skia库、配置NDK项目。
编译Skia(简要流程,实际工程中会在CI里执行):
bash
# 使用HarmonyOS NDK toolchain编译
git clone https://skia.googlesource.com/skia
cd skia
patch -p1 < <你的HarmonyOS编译补丁>
python3 tools/git-sync-deps
bin/gn gen out/ohos --args="target_cpu=\"arm64\" is_official_build=true skia_use_egl=false skia_use_gl=true"
ninja -C out/ohos skia
编译后得到libskia.a和头文件目录。
配置NDK项目 :创建native/子目录,CMakeLists.txt配置如下:
cmake
cmake_minimum_required(VERSION 3.4.0)
project("skia_demo")
set(CMAKE_CXX_STANDARD 17)
# 导入Skia
add_library(skia STATIC IMPORTED)
set_target_properties(skia PROPERTIES IMPORTED_LOCATION ${CMAKE_CURRENT_SOURCE_DIR}/../skia/out/ohos/libskia.a)
# 设置头文件路径
target_include_directories(skia_demo PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/../skia/include
${CMAKE_CURRENT_SOURCE_DIR}/../skia/include/core
${CMAKE_CURRENT_SOURCE_DIR}/../skia/include/effects
${CMAKE_CURRENT_SOURCE_DIR}/../skia/include/utils
)
target_link_libraries(skia_demo PUBLIC skia)
第二步:创建OHOS Native组件
在ArkUI端,通过XComponent创建Native渲染区域:
typescript
// 入口页面 Index.ets
import { XComponentContext } from '@ohos.multimedia.xcomponent';
import nativeRender from 'libskia_render.so';
@Entry
@Component
struct SkiaDemoPage {
private xcomponentContext: XComponentContext | null = null;
@State renderWidth: number = 0;
@State renderHeight: number = 0;
build() {
Column() {
XComponent({
id: 'skia_render',
type: XComponentType.SURFACE,
libraryName: 'skia_render'
})
.onLoad((xcomponentContext: XComponentContext) => {
this.xcomponentContext = xcomponentContext;
// 获取渲染区域尺寸
let rect = xcomponentContext.getXComponentSurfaceRect();
this.renderWidth = rect.width;
this.renderHeight = rect.height;
// 调用Native初始化
nativeRender.initWithSurface(xcomponentContext);
})
.width('100%')
.height('100%')
.backgroundColor(Color.White)
}
.width('100%')
.height('100%')
.padding(10)
}
}
这里的关键是libraryName: 'skia_render',它会让系统加载libskia_render.so。onLoad回调里拿到XComponentContext后,传给Native层初始化渲染上下文。
第三步:Native层实现Skia渲染
这是最核心的部分,需要完成:接收XComponent的surface、创建Skia渲染目标、执行绘制。
cpp
// native_render.cpp
#include <string>
#include <cmath>
#include <napi/native_api.h>
#include <multimedia/xcomponent/xcomponent_native.h>
#include <native_window/xcomponent/xcomponent_nativewindow.h>
#include <multimedia/player/player_xcomponent.h>
#include "include/core/SkSurface.h"
#include "include/core/SkCanvas.h"
#include "include/core/SkPaint.h"
#include "include/core/SkPath.h"
#include "include/core/SkFont.h"
#include "include/core/SkTypeface.h"
#include "include/core/SkTextBlob.h"
#include "window.h"
// 全局变量:保存XComponent实例和Skia surface
static OH_NativeXComponent* g_nativeXComponent = nullptr;
static SkSurface* g_skSurface = nullptr;
static int32_t g_surfaceWidth = 0;
static int32_t g_surfaceHeight = 0;
// 初始化渲染上下文
napi_value InitWithSurface(napi_env env, napi_callback_info info) {
size_t argc = 1;
napi_value argv[1];
napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr);
// 从ArkTS传入的XComponentContext获取native组件
napi_valuetype valuetype;
napi_typeof(env, argv[0], &valuetype);
// 通过NAPI获取OH_NativeXComponent实例
// 实际工程中更推荐从XComponent的onLoad回调直接传递native实例
OH_NativeXComponent* nativeXComponent = nullptr;
napi_get_native_xcomponent(env, argv[0], &nativeXComponent);
if (nativeXComponent == nullptr) {
// 如果获取失败,尝试从XComponent ID获取
// 这里简化处理,假设直接拿到
}
g_nativeXComponent = nativeXComponent;
// 获取surface宽高
OH_NativeXComponent_GetXComponentSize(nativeXComponent, nullptr, &g_surfaceWidth, &g_surfaceHeight);
// 获取native window
void* nativeWindow = nullptr;
OH_NativeXComponent_GetNativeWindow(nativeXComponent, &nativeWindow);
OHNativeWindow* window = reinterpret_cast<OHNativeWindow*>(nativeWindow);
// 创建Skia surface绑定到native window
g_skSurface = SkSurface::MakeFromOHNativeWindow(
window,
SkSurface::Origin::kTopLeft_Origin,
nullptr
).release();
// 首次绘制
DrawScene();
return nullptr;
}
// 绘制函数
void DrawScene() {
if (g_skSurface == nullptr) return;
SkCanvas* canvas = g_skSurface->getCanvas();
canvas->clear(SK_ColorWHITE);
// ---- 绘制矢量图形 ----
SkPaint paint;
paint.setAntiAlias(true);
// 绘制贝塞尔曲线路径
SkPath path;
path.moveTo(50, 150);
path.cubicTo(100, 50, 200, 250, 250, 150);
paint.setColor(SK_ColorBLUE);
paint.setStyle(SkPaint::kStroke_Style);
paint.setStrokeWidth(4);
canvas->drawPath(path, paint);
// 绘制渐变圆
SkPaint gradientPaint;
SkPoint points[] = {{50, 50}, {150, 150}};
SkColor colors[] = {SK_ColorRED, SK_ColorYELLOW};
auto gradient = SkGradientShader::MakeLinear(
points, colors, nullptr, 2, SkTileMode::kClamp
);
gradientPaint.setShader(gradient);
canvas->drawCircle(150, 100, 80, gradientPaint);
// ---- 文字排版 ----
// 加载字体文件(需放置到resources/rawfile目录)
// 实际工程中需通过资源文件路径加载
SkFont font;
font.setSize(36);
// 设置字体样式(粗体)
font.setEmbolden(true);
// 创建文字块
SkPaint textPaint;
textPaint.setColor(SK_ColorBLACK);
textPaint.setAntiAlias(true);
const char* text = "HarmonyOS NDK & Skia";
canvas->drawString(text, 50, 300, font, textPaint);
// 绘制多行文字
SkPaint subtitlePaint;
subtitlePaint.setColor(SK_ColorGRAY);
subtitlePaint.setAntiAlias(true);
SkFont subtitleFont;
subtitleFont.setSize(18);
canvas->drawString("复杂矢量图形和文字排版支持", 50, 340, subtitleFont, subtitlePaint);
// ---- 提交渲染结果 ----
g_skSurface->flushAndSubmit();
}
// NAPI注册
static napi_value Init(napi_env env, napi_value exports) {
napi_property_descriptor desc[] = {
{ "initWithSurface", nullptr, InitWithSurface, nullptr, nullptr, nullptr, napi_default, nullptr }
};
napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
return exports;
}
NAPI_MODULE(skia_render, Init)
这段代码里有几个关键点:
-
创建Skia Surface :通过
SkSurface::MakeFromOHNativeWindow将Skia的渲染目标绑定到HarmonyOS的Native Window上。这是ArkUI Native组件和Skia渲染管线之间的桥梁。 -
绘制流程 :和标准Skia用法一致------获取Canvas、设置画笔、绘制路径和文字、最后调用
flushAndSubmit提交渲染指令。 -
资源管理:Skia的Surface在组件销毁时需要释放,但这里仅做演示。
第四步:处理组件生命周期
ArkUI的XComponent有自己的生命周期回调,需要在Native层注册处理函数:
cpp
// 在初始化时注册回调
static void OnSurfaceCreated(OH_NativeXComponent* component, void* window) {
// surface已创建,可以开始渲染
initSkiaSurface(window);
}
static void OnSurfaceChanged(OH_NativeXComponent* component, void* window) {
// 窗口大小变化,重新创建surface
delete g_skSurface;
initSkiaSurface(window);
DrawScene();
}
static void OnSurfaceDestroyed(OH_NativeXComponent* component, void* window) {
// 销毁前释放资源
delete g_skSurface;
g_skSurface = nullptr;
}
// 注册回调
OH_NativeXComponent_Callback callback;
callback.OnSurfaceCreated = OnSurfaceCreated;
callback.OnSurfaceChanged = OnSurfaceChanged;
callback.OnSurfaceDestroyed = OnSurfaceDestroyed;
OH_NativeXComponent_RegisterCallback(g_nativeXComponent, &callback);
不注册生命周期回调的后果是:当页面返回或切换时,渲染资源不会释放,可能导致内存泄漏或后续渲染错乱。
踩坑记录
坑1:Skia渲染性能下降------像素缓冲区问题
现象 :首次启动时渲染流畅,但连续调用flushAndSubmit后出现明显卡顿,帧率下降到个位数。
原因 :Skia在创建Surface时默认使用单缓冲模式。每次flushAndSubmit后,Skia会等待GPU完成渲染再返回,导致CPU和GPU无法并行工作。如果渲染内容复杂,每帧的等待时间会累积。
解决方案:使用双缓冲模式:
cpp
// 创建Surface时指定双缓冲
SkSurfaceProps props;
props.setBufferMode(SkSurfaceProps::BufferMode::kDouble);
g_skSurface = SkSurface::MakeFromOHNativeWindow(
window,
SkSurface::Origin::kTopLeft_Origin,
&props
).release();
双缓冲模式下,Skia会维护两个缓冲区:一个用于GPU渲染,一个用于显示。CPU提交后立即返回,GPU继续渲染,利用率大幅提升。
坑2:字体文件加载失败
现象 :canvas->drawString无任何文字输出,但矢量图形正常。
原因:Skia默认使用系统字体,但在HarmonyOS环境下,系统字体路径与Android/Linux不同。直接使用默认字体时,Skia可能找不到可用的字体文件。
解决方案 :手动指定字体文件路径或使用SkTypeface::MakeFromName指定字体族名称:
cpp
// 方式1:指定字体文件路径(需将字体文件放置到rawfile中,运行时读取)
// 这里假设字体文件已解压到/data/storage/el2/base/haps/entry/files/目录
sk_sp<SkTypeface> typeface = SkTypeface::MakeFromFile("/data/storage/el2/base/haps/entry/files/Roboto-Regular.ttf");
if (typeface) {
font.setTypeface(typeface);
}
// 方式2:使用系统字体族名称(取决于HarmonyOS支持的字体)
font.setTypeface(SkTypeface::MakeFromName("HarmonyOS Sans", SkFontStyle::Normal()));
更稳定的做法是将字体文件打包到resources/rawfile下,在Native层通过NAPI接口读取文件内容后再加载。
最佳实践
-
不要在ArkUI的build()中频繁调用Native渲染函数 。每次build()都会触发渲染,但Skia的
flushAndSubmit会提交GPU指令。如果build()在动画循环中被频繁调用(例如每16ms一次),GPU压力会非常大。建议在Native层用独立的定时器控制渲染频率,ArkUI只负责触发启动渲染循环。 -
渲染任务异步化 。Skia的
DrawScene()如果在ArkUI主线程执行,会阻塞UI更新。需要将flushAndSubmit放到单独的渲染线程中执行。但需要注意线程安全性------Skia的SkSurface不是线程安全的,同一个Surface的所有操作应在同一线程完成。 -
使用像素缓冲区提升连续渲染性能 。如果需要频繁更新渲染内容(如实时数据图表),推荐使用
SkPixelBuffer或SkColorSpace管理色彩空间,避免每次绘制都重新创建字体、路径等对象。这些对象的创建成本较高。
FAQ
Q:为什么真机渲染效果正常,模拟器上文字会显示乱码或消失?
A:模拟器的设备型号和真实设备在字体配置上存在差异。模拟器可能缺少某些系统字体文件。解决方案是在resources/rawfile中打包一份通用字体如Roboto-Regular.ttf,在Native层手动加载。
Q:页面返回后重新进入,Skia渲染区域变成黑屏?
A:这是生命周期管理问题。页面返回时,XComponent的Surface会被销毁,但Native层的Skia Surface对象没有及时释放。再次进入时,旧的Skia Surface指向一个已失效的Native Window。解决方案是在OnSurfaceDestroyed回调中清空Skia Surface对象,并在OnSurfaceCreated中重新创建。
Q:为什么第一次绘制很快,后续多次绘制后内存占用持续增长?
A:检查Skia版本的幻影图层(Overdraw)问题。某些版本的Skia在创建SkSurface时,如果没有指定合适的SkColorSpace,每次flushAndSubmit时会泄漏像素缓冲区对象。可尝试将Surface的makeRasterImage调用注释掉,或者改用SkSurface::MakeRenderTarget创建离屏渲染目标。