《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,性能差异很明显。
环境说明
DevEco Studio 版本:DevEco Studio 6.1.0 及以上 HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上 目标设备:手机、平板核心实现:集成Skia到NDK项目
第一步:准备Skia库
需要做两件事:编译Skia库、配置NDK项目。
编译Skia(简要流程,实际工程中会在CI里执行):
# 使用HarmonyOS NDK toolchain编译gitclone https://skia.googlesource.com/skiacdskia 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-Cout/ohos skia编译后得到libskia.a和头文件目录。
配置NDK项目:创建native/子目录,CMakeLists.txt配置如下:
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渲染区域:
// 入口页面 Index.etsimport{XComponentContext}from'@ohos.multimedia.xcomponent';importnativeRenderfrom'libskia_render.so';@Entry@Componentstruct SkiaDemoPage{privatexcomponentContext:XComponentContext|null=null;@StaterenderWidth:number=0;@StaterenderHeight:number=0;build(){Column(){XComponent({id:'skia_render',type:XComponentType.SURFACE,libraryName:'skia_render'}).onLoad((xcomponentContext:XComponentContext)=>{this.xcomponentContext=xcomponentContext;// 获取渲染区域尺寸letrect=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渲染目标、执行绘制。
// 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 surfacestaticOH_NativeXComponent*g_nativeXComponent=nullptr;staticSkSurface*g_skSurface=nullptr;staticint32_tg_surfaceWidth=0;staticint32_tg_surfaceHeight=0;// 初始化渲染上下文napi_valueInitWithSurface(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 windowvoid*nativeWindow=nullptr;OH_NativeXComponent_GetNativeWindow(nativeXComponent,&nativeWindow);OHNativeWindow*window=reinterpret_cast<OHNativeWindow*>(nativeWindow);// 创建Skia surface绑定到native windowg_skSurface=SkSurface::MakeFromOHNativeWindow(window,SkSurface::Origin::kTopLeft_Origin,nullptr).release();// 首次绘制DrawScene();returnnullptr;}// 绘制函数voidDrawScene(){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};autogradient=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);constchar*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注册staticnapi_valueInit(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);returnexports;}NAPI_MODULE(skia_render,Init)这段代码里有几个关键点:
创建Skia Surface:通过
SkSurface::MakeFromOHNativeWindow将Skia的渲染目标绑定到HarmonyOS的Native Window上。这是ArkUI Native组件和Skia渲染管线之间的桥梁。绘制流程:和标准Skia用法一致——获取Canvas、设置画笔、绘制路径和文字、最后调用
flushAndSubmit提交渲染指令。资源管理:Skia的Surface在组件销毁时需要释放,但这里仅做演示。
第四步:处理组件生命周期
ArkUI的XComponent有自己的生命周期回调,需要在Native层注册处理函数:
// 在初始化时注册回调staticvoidOnSurfaceCreated(OH_NativeXComponent*component,void*window){// surface已创建,可以开始渲染initSkiaSurface(window);}staticvoidOnSurfaceChanged(OH_NativeXComponent*component,void*window){// 窗口大小变化,重新创建surfacedeleteg_skSurface;initSkiaSurface(window);DrawScene();}staticvoidOnSurfaceDestroyed(OH_NativeXComponent*component,void*window){// 销毁前释放资源deleteg_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无法并行工作。如果渲染内容复杂,每帧的等待时间会累积。
解决方案:使用双缓冲模式:
// 创建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指定字体族名称:
// 方式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创建离屏渲染目标。
