《HarmonyOS技术精讲-UI开发 (基于NDK构建UI)》第6篇:集成第三方C++图形库——以Skia为例

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.soonLoad回调里拿到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)

这段代码里有几个关键点:

  1. 创建Skia Surface :通过SkSurface::MakeFromOHNativeWindow将Skia的渲染目标绑定到HarmonyOS的Native Window上。这是ArkUI Native组件和Skia渲染管线之间的桥梁。

  2. 绘制流程 :和标准Skia用法一致------获取Canvas、设置画笔、绘制路径和文字、最后调用flushAndSubmit提交渲染指令。

  3. 资源管理: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接口读取文件内容后再加载。

最佳实践

  1. 不要在ArkUI的build()中频繁调用Native渲染函数 。每次build()都会触发渲染,但Skia的flushAndSubmit会提交GPU指令。如果build()在动画循环中被频繁调用(例如每16ms一次),GPU压力会非常大。建议在Native层用独立的定时器控制渲染频率,ArkUI只负责触发启动渲染循环。

  2. 渲染任务异步化 。Skia的DrawScene()如果在ArkUI主线程执行,会阻塞UI更新。需要将flushAndSubmit放到单独的渲染线程中执行。但需要注意线程安全性------Skia的SkSurface不是线程安全的,同一个Surface的所有操作应在同一线程完成。

  3. 使用像素缓冲区提升连续渲染性能 。如果需要频繁更新渲染内容(如实时数据图表),推荐使用SkPixelBufferSkColorSpace管理色彩空间,避免每次绘制都重新创建字体、路径等对象。这些对象的创建成本较高。

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创建离屏渲染目标。