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

📅 2026/6/25 13:39:26
《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 CanvasSkia NDK简单2D图形矩形、圆形、直线简单直接大材小用复杂矢量图形贝塞尔曲线、路径裁剪性能受限支持有限原生支持性能可控大量文字排版多语言、复杂排版功能有限完整排版引擎实时动画/交互式绘图有性能瓶颈利用CPU/GPU渲染跨平台代码复用仅限鸿蒙可复用其他平台为什么不直接用OpenGL ESOpenGL ES确实能提供最高的渲染性能但它需要开发者自己处理更多的底层逻辑顶点缓冲、着色器编译、帧缓冲区管理等。Skia对这些细节做了封装对于2D图形渲染而言用Skia的开发效率远高于OpenGL ES且效果不差。如果你的目标是绘制2D图形而不是3D场景Skia通常是更务实的选择。为什么不直接用ArkUI CanvasArkUI 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--argstarget_cpu\arm64\is_official_buildtrue skia_use_eglfalse skia_use_gltrueninja-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}fromohos.multimedia.xcomponent;importnativeRenderfromlibskia_render.so;EntryComponentstruct SkiaDemoPage{privatexcomponentContext:XComponentContext|nullnull;StaterenderWidth:number0;StaterenderHeight:number0;build(){Column(){XComponent({id:skia_render,type:XComponentType.SURFACE,libraryName:skia_render}).onLoad((xcomponentContext:XComponentContext){this.xcomponentContextxcomponentContext;// 获取渲染区域尺寸letrectxcomponentContext.getXComponentSurfaceRect();this.renderWidthrect.width;this.renderHeightrect.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#includestring#includecmath#includenapi/native_api.h#includemultimedia/xcomponent/xcomponent_native.h#includenative_window/xcomponent/xcomponent_nativewindow.h#includemultimedia/player/player_xcomponent.h#includeinclude/core/SkSurface.h#includeinclude/core/SkCanvas.h#includeinclude/core/SkPaint.h#includeinclude/core/SkPath.h#includeinclude/core/SkFont.h#includeinclude/core/SkTypeface.h#includeinclude/core/SkTextBlob.h#includewindow.h// 全局变量保存XComponent实例和Skia surfacestaticOH_NativeXComponent*g_nativeXComponentnullptr;staticSkSurface*g_skSurfacenullptr;staticint32_tg_surfaceWidth0;staticint32_tg_surfaceHeight0;// 初始化渲染上下文napi_valueInitWithSurface(napi_env env,napi_callback_info info){size_t argc1;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*nativeXComponentnullptr;napi_get_native_xcomponent(env,argv[0],nativeXComponent);if(nativeXComponentnullptr){// 如果获取失败尝试从XComponent ID获取// 这里简化处理假设直接拿到}g_nativeXComponentnativeXComponent;// 获取surface宽高OH_NativeXComponent_GetXComponentSize(nativeXComponent,nullptr,g_surfaceWidth,g_surfaceHeight);// 获取native windowvoid*nativeWindownullptr;OH_NativeXComponent_GetNativeWindow(nativeXComponent,nativeWindow);OHNativeWindow*windowreinterpret_castOHNativeWindow*(nativeWindow);// 创建Skia surface绑定到native windowg_skSurfaceSkSurface::MakeFromOHNativeWindow(window,SkSurface::Origin::kTopLeft_Origin,nullptr).release();// 首次绘制DrawScene();returnnullptr;}// 绘制函数voidDrawScene(){if(g_skSurfacenullptr)return;SkCanvas*canvasg_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};autogradientSkGradientShader::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*textHarmonyOS 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_skSurfacenullptr;}// 注册回调OH_NativeXComponent_Callback callback;callback.OnSurfaceCreatedOnSurfaceCreated;callback.OnSurfaceChangedOnSurfaceChanged;callback.OnSurfaceDestroyedOnSurfaceDestroyed;OH_NativeXComponent_RegisterCallback(g_nativeXComponent,callback);不注册生命周期回调的后果是当页面返回或切换时渲染资源不会释放可能导致内存泄漏或后续渲染错乱。踩坑记录坑1Skia渲染性能下降——像素缓冲区问题现象首次启动时渲染流畅但连续调用flushAndSubmit后出现明显卡顿帧率下降到个位数。原因Skia在创建Surface时默认使用单缓冲模式。每次flushAndSubmit后Skia会等待GPU完成渲染再返回导致CPU和GPU无法并行工作。如果渲染内容复杂每帧的等待时间会累积。解决方案使用双缓冲模式// 创建Surface时指定双缓冲SkSurfaceProps props;props.setBufferMode(SkSurfaceProps::BufferMode::kDouble);g_skSurfaceSkSurface::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_spSkTypefacetypefaceSkTypeface::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管理色彩空间避免每次绘制都重新创建字体、路径等对象。这些对象的创建成本较高。FAQQ为什么真机渲染效果正常模拟器上文字会显示乱码或消失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创建离屏渲染目标。