实战:ArkTS与Kuikly混合开发——打造HarmonyOS原生级水印图片应用

摘要:随着HarmonyOS Next的推进,原生应用开发成为热点。如何在保持跨平台高效开发的同时,又能充分利用鸿蒙原生ArkTS的强大能力?本文将通过一个“图片水印工具”实战项目,深度解析 Kuikly框架(Kotlin DSL)ArkTS(Native) 的混合开发模式。我们将从零开始,构建一个集图片选择、Canvas绘图、沙箱文件管理于一体的鸿蒙应用,并解决开发过程中遇到的类型安全、API兼容性等真实挑战。


1. 引言:为什么选择“混合开发”?

在移动应用开发中,我们常面临一个两难选择:

  • 跨平台框架(如Kuikly/KMP):一套代码运行在Android、iOS和HarmonyOS上,开发效率极高,适合业务逻辑和通用UI。
  • 原生开发(ArkTS):直接调用系统底层API(如PhotoViewPicker、Canvas Drawing),性能最好,功能最全。

此外,选择混合开发还有一个重要原因:框架的成熟度
Kuikly作为一个新兴的跨平台框架,虽然在业务逻辑复用上表现出色,但在某些特定领域的原生能力支持上(如复杂的高性能绘图、最新的系统API适配)尚在完善中。
与其等待框架封装所有能力,不如 缺什么补什么 ——利用ArkTS直接实现那些框架尚未覆盖或难以完美封装的功能。

1.1 混合开发模式对比

特性 纯 Kuikly/KMP 纯 ArkTS Kuikly + ArkTS 混合模式
开发效率 高 (一处编写,多端运行) 中 (仅针对鸿蒙) 高 (业务复用 + 能力互补)
性能表现 中高 (接近原生) 极高 (原生) 极高 (关键路径原生优化)
系统能力 依赖官方/社区封装 100% 覆盖 100% 覆盖 (随时可调原生接口)
UI 灵活性 DSL 声明式 ArkUI 声明式 灵活 (DSL 主体 + ArkTS 插件)
适用场景 列表、详情页、表单 复杂动画、底层硬件调用 常规业务快速迭代 + 核心功能深度定制

KuiklyUI-photo 项目通过“Kotlin DSL写业务 + Native Module调能力”的架构,完美融合了两者。本文将展示如何在Kuikly应用中,嵌入一个纯ArkTS编写的高性能图片水印模块。

2. 项目架构与工程化设计

本项目基于 KuiklyUI-mini 模板,该模板专为轻量级混合开发设计,结构如下:

KuiklyUI-mini/
├── androidApp/          # Android 宿主
├── iosApp/              # iOS 宿主
├── ohosApp/             # HarmonyOS 宿主 (ArkTS)
│   ├── entry/src/main/ets/
│   │   ├── pages/
│   │   │   ├── Index.ets           # Kuikly渲染入口 (承载DSL页面)
│   │   │   └── NativeGalleryPage.ets # [核心] 原生水印页面 (ArkTS实现)
│   │   └── kuikly/modules/
│   │       └── KRBridgeModule.ets  # [核心] 跨端桥接模块
│   └── build-profile.json5         # 鸿蒙构建配置
└── shared/              # Kotlin Multiplatform 共享层
    └── src/commonMain/kotlin/
        └── GalleryPage.kt          # 通用业务逻辑

模板项目地址:Kuikly-mini
在这里插入图片描述
项目地址: Kuikly-photo
在这里插入图片描述

2.1 模块分工

  • shared (KMP): 负责通用的页面逻辑、网络请求、数据模型定义以及简单的UI布局(如列表页、设置页)。这部分代码编译后会生成 .har.so 供鸿蒙工程调用。
  • ohosApp (ArkTS): 负责鸿蒙特有的能力实现,如文件系统访问、高性能绘图、传感器调用等。对于性能要求极高或界面交互非常复杂的场景,直接使用 ArkTS 编写 Component

2.2 编译与运行流程

  1. Shared 编译: Gradle 将 shared 模块的 Kotlin 代码编译为跨平台中间产物。
  2. 资源同步: 构建脚本将生成的 JS/Native 产物复制到 ohosApp 的资源目录。
  3. HAP 打包: DevEco Studio 将 ArkTS 代码与 Kuikly 运行时打包成 .hap 文件。
  4. 运行时加载: 应用启动时,Kuikly 引擎加载 Shared 层的 DSL,渲染出原生组件;当需要调用特定功能时,通过 Bridge 唤起 ArkTS 模块。

3. 核心功能实现:NativeGalleryPage.ets

这是我们本次实战的重头戏。我们需要用ArkTS实现一个页面,具备以下功能:

  1. 选择图片:调用鸿蒙系统相册。
  2. 参数设置:自定义水印文字、颜色、字体样式、时间戳。
  3. 合成水印:使用 ohos.graphics.drawing 进行位图处理。
  4. 预览与保存:展示处理后的图片。

3.1 强类型状态管理与模型定义

为了符合ArkTS严格的类型检查(arkts-no-any-unknown),我们首先定义数据模型。在 HarmonyOS Next 中,ArkTS 采用了更严格的静态类型检查,这虽然增加了初期的编码成本,但极大地提高了运行时的稳定性和性能。

// 定义颜色结构,用于Canvas绘制时的颜色配置
class WatermarkColor {
  alpha: number = 255
  red: number = 0
  green: number = 0
  blue: number = 0
}

// 定义选项Item结构,用于UI列表渲染
class ColorItem {
  name: string = ''
  value: WatermarkColor = new WatermarkColor()
}

class FontStyleItem {
  name: string = ''
  value: string = ''
}

在组件中管理状态,我们使用了 @State 装饰器。这是 ArkUI 响应式编程的核心,当 @State 变量改变时,依赖该变量的 UI 组件会自动重新渲染。

@Entry
@Component
struct GalleryNativePage {
  // 图片路径,使用 @State 驱动 Image 组件刷新
  @State imagePath: string = '';
  // 处理后的图片路径
  @State watermarkedPath: string = '';
  // 水印文字配置
  @State watermarkText: string = 'Kuikly Native Watermark';
  @State watermarkColor: WatermarkColor = { alpha: 255, red: 255, green: 0, blue: 0 };
  @State selectedFontStyle: string = 'Normal';
  @State showTimestamp: boolean = false;
  
  // 预定义配置项,无需响应式变化,作为普通成员变量
  private colors: Array<ColorItem> = [
    { name: 'Red', value: { alpha: 255, red: 255, green: 0, blue: 0 } },
    { name: 'Green', value: { alpha: 255, red: 0, green: 255, blue: 0 } },
    { name: 'Blue', value: { alpha: 255, red: 0, green: 0, blue: 255 } },
    { name: 'Black', value: { alpha: 255, red: 0, green: 0, blue: 0 } },
    { name: 'White', value: { alpha: 255, red: 255, green: 255, blue: 255 } }
  ];
  // ... 其他状态

3.2 原生UI交互:构建参数配置面板

为了让用户能实时调整水印效果,我们利用ArkTS声明式UI构建了一个配置面板。这部分代码展示了如何使用 TextInputToggleFlex 布局来构建交互界面。ArkUI 的声明式语法非常直观,类似 Flutter 或 SwiftUI。

1. 水印文字输入 (TextInput)

// 水印文字输入
Text("水印文字:")
  .alignSelf(ItemAlign.Start)
  .margin({ bottom: 5 })

TextInput({ text: this.watermarkText, placeholder: '请输入水印文字' })
  .onChange((value) => {
    // 实时更新状态,驱动UI重绘
    this.watermarkText = value;
  })
  .margin({ bottom: 15 })
  .width('100%')

2. 颜色与样式选择 (Flex布局)

为了美观地展示颜色选项,我们使用了 Flex 布局包裹 Circle 组件。Flex 布局在处理不定数量、需要自动换行的子元素时非常强大。

// 颜色选择区域
Text("水印颜色:")
  .alignSelf(ItemAlign.Start)
  .margin({ bottom: 5 })

Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
  ForEach(this.colors, (item: ColorItem, index: number) => {
    Circle({ width: 30, height: 30 })
      .fill(this.getUIAbilityColor(item.value)) // 辅助函数转换颜色格式
      .stroke(this.selectedColorIndex === index ? Color.Gray : Color.Transparent) // 选中态描边
      .strokeWidth(2)
      .margin({ right: 10, bottom: 10 })
      .onClick(() => {
        this.selectedColorIndex = index;
        this.watermarkColor = item.value;
      })
  })
}
.margin({ bottom: 15 })

3. 时间戳开关 (Toggle)

// 时间戳开关
Row() {
  Text("添加时间戳")
    .margin({ right: 10 })
  Toggle({ type: ToggleType.Switch, isOn: this.showTimestamp })
    .onChange((isOn: boolean) => {
      this.showTimestamp = isOn;
    })
}
.width('100%')
.justifyContent(FlexAlign.Start)
.margin({ bottom: 15 })

3.3 调用系统相册与沙箱处理

鸿蒙系统的安全机制要求我们不能直接使用相册的原始URI进行所有操作(尤其是跨应用/跨进程读取时),通常建议将文件复制到应用的沙箱目录(CacheDir)下。

为什么需要沙箱?
应用沙箱是一种安全隔离机制,防止恶意应用读取其他应用的数据。通过 PhotoViewPicker 获取的 URI 实际上是一个临时授权的访问凭证。为了后续稳定地进行图片处理(如解码、重编码),将其持久化到应用自身的私有目录是最稳妥的做法。

import picker from '@ohos.file.picker';
import fs from '@ohos.file.fs';

async selectImage() {
  try {
    const photoSelectOptions = new picker.PhotoSelectOptions();
    photoSelectOptions.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
    photoSelectOptions.maxSelectNumber = 1;
    const photoViewPicker = new picker.PhotoViewPicker();
    const photoSelectResult = await photoViewPicker.select(photoSelectOptions);
    
    if (photoSelectResult.photoUris.length > 0) {
      let uri = photoSelectResult.photoUris[0];
      // 获取上下文
      const context = getContext(this) as common.UIAbilityContext;
      // 打开原始文件
      const file = fs.openSync(uri, fs.OpenMode.READ_ONLY);
      // 构造沙箱路径
      const fileName = `picked_${Date.now()}.jpg`;
      const outPath = context.cacheDir + '/' + fileName;
      // 复制文件
      fs.copyFileSync(file.fd, outPath);
      fs.closeSync(file);
      
      // 更新状态,fileUri用于Image组件显示
      // 这一步至关重要,确保Image组件能通过标准协议加载图片
      this.imagePath = fileUri.getUriFromPath(outPath);
    }
  } catch (err) {
    console.error('selectImage failed', err);
  }
}

3.4 高性能绘图:Canvas与PixelMap

这是水印功能的核心。我们使用 @ohos.multimedia.image 解码图片,使用 @ohos.graphics.drawing 进行绘制。

技术背景
鸿蒙的 Drawing API 是基于 Skia 图形库的底层封装,提供了比 ArkUI Canvas 组件更底层的控制能力和更高的性能。它允许我们直接在 PixelMap(位图内存)上进行操作,不需要将图片渲染到屏幕上的 Canvas 组件即可完成处理,非常适合后台图片处理任务。

import image from '@ohos.multimedia.image';
import drawing from '@ohos.graphics.drawing';

async addWatermark() {
  if (!this.imagePath) return;
  try {
    // 1. 创建 PixelMap (设置为可编辑)
    // 注意:默认解码出来的 PixelMap 可能是只读的,必须设置 editable: true
    const file = fs.openSync(this.imagePath, fs.OpenMode.READ_ONLY);
    const imageSource = image.createImageSource(file.fd);
    const decodingOptions: image.DecodingOptions = {
      editable: true,
      desiredPixelFormat: image.PixelMapFormat.RGBA_8888, // 推荐使用 RGBA_8888 格式,兼容性最好
    }
    const pixelMap = await imageSource.createPixelMap(decodingOptions);

    // 2. 绑定 Drawing Canvas
    // 将 Canvas 的绘制目标绑定到 pixelMap 的内存地址
    const canvas = new drawing.Canvas(pixelMap);

    // 3. 配置画笔 (Brush) 和 字体 (Font)
    const brush = new drawing.Brush();
    brush.setColor(this.watermarkColor); // 直接使用我们定义的 WatermarkColor 对象
    
    const font = new drawing.Font();
    // 动态计算字号:根据图片宽度自适应,避免大图文字过小
    font.setSize(pixelMap.getImageInfoSync().size.width / 20); 

    // 4. 处理字体样式 (粗体/斜体)
    // 提示:部分API在不同版本鸿蒙SDK中支持度不同,需做兼容处理
    if (this.selectedFontStyle === 'Italic' || this.selectedFontStyle === 'BoldItalic') {
       font.setSkewX(-0.25); // 设置倾斜,模拟斜体效果
    }
    // 注意:setFakeBoldText 等 API 可能在部分设备上不可用,生产环境建议引入完整的字体文件

    // 5. 绘制文本
    // TextBlob 是文本绘制的高效封装,支持复杂的排版
    let finalWatermarkText = this.watermarkText;
    if (this.showTimestamp) {
        // 追加时间戳逻辑
        const now = new Date();
        finalWatermarkText += `\n${now.getFullYear()}-${now.getMonth()+1}-${now.getDate()}`;
    }

    const textBlob = drawing.TextBlob.makeFromString(
        finalWatermarkText, 
        font, 
        drawing.TextEncoding.TEXT_ENCODING_UTF8
    );
    canvas.attachBrush(brush);
    // 绘制位置:左下角偏移
    canvas.drawTextBlob(textBlob, 50, pixelMap.getImageInfoSync().size.height - 100);
    canvas.detachBrush();

    // 6. 重新打包保存
    // 将修改后的 PixelMap 重新编码为 JPEG
    const imagePacker = image.createImagePacker();
    const data = await imagePacker.packing(pixelMap, { format: "image/jpeg", quality: 98 });
    
    // 写入沙箱
    const outPath = getContext(this).cacheDir + `/watermarked_${Date.now()}.jpg`;
    const outFile = fs.openSync(outPath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
    fs.writeSync(outFile.fd, data);
    
    // 显示结果
    this.watermarkedPath = fileUri.getUriFromPath(outPath);
    
  } catch (err) {
    console.error("addWatermark failed", err);
  }
}

4. 跨端桥接:KRBridgeModule

如何从Kuikly页面跳转到这个原生ArkTS页面?KuiklyUI-mini 提供了灵活的模块注册机制,允许我们将原生能力封装为 KRModule,供 Shared 层调用。

我们在 KRBridgeModule.ets 中建立桥梁,通过 JSON 传递参数,实现灵活的页面跳转与数据交互。

// ohosApp/entry/src/main/ets/kuikly/modules/KRBridgeModule.ets
import router from '@ohos.router';

export class KRBridgeModule extends KRModule {
  
  // 暴露给 Shared 层的接口
  // KRAny 通常是一个 JSON 字符串,这种弱类型设计为了兼容不同平台的参数结构
  private openPage(params: KRAny) {
    try {
      let records = JSON.parse(params as string) as Record<string, string>;
      let url = records["url"]; // 目标页面名
      
      if (url) {
        // 使用鸿蒙原生路由跳转
        // 注意:目标页面必须在 main_pages.json 中注册
        router.pushUrl({
          url: 'pages/Index', // 这里的 'pages/Index' 是为了演示,实际上应该根据 url 参数映射到具体的 Native 页面
          params: { pageName: url }
        })
      }
    } catch (e) {
      console.error(`openPage failed: ${e}`)
    }
  }
}

在 Kotlin (Shared层) 中,我们只需简单调用:

// shared/src/commonMain/kotlin/.../RouterPage.kt
fun openNativeWatermarkPage() {
    // 构建 JSON 参数
    KRBridgeModule.openPage(JSONObject().apply {
        put("url", "native_watermark")
    }.toString())
}

5. 开发中的“坑”与经验总结

在实际开发过程中,我们遇到了一些跨平台与原生对接的典型问题,以下是我们的解决方案。

5.1 类型检查陷阱 (ArkTS Strict Mode)

鸿蒙 ArkTS 对 any 类型的容忍度越来越低。在开发初期,为了图省事使用了 colors: Array<any>,导致编译器报错:

Use explicit types instead of "any", "unknown" (arkts-no-any-unknown)

解决方案

  • 严禁使用 any:即使是临时代码,也尽量定义 interfaceclass
  • 使用 Record 类型:对于键值对结构,使用 Record<string, Object> 替代 Map<any, any>
  • 显式类型转换:在调用系统 API 返回 Object 时,使用 as 关键字进行安全的类型断言。

5.2 命名空间冲突

在使用 common.Color 时,发现 @ohos.app.ability.common 并没有导出 Color,而 UI 组件库中有 Color 枚举,Drawing 库中又有自己的颜色定义。

解决方案

  • 自定义 WatermarkColor 类,明确 RGBA 结构,避免依赖模糊的系统类型。
  • 编写辅助函数 getUIAbilityColor 将数据模型转换为 UI 组件需要的 CSS 样式字符串(如 rgba(255,0,0,1))。

5.3 Drawing API 版本差异

drawing.Typeface.makeDefault()font.setFakeBoldText() 在某些SDK版本中可能未开放或行为不一致。鸿蒙 API 迭代速度极快,文档有时会滞后。

解决方案

  • 防御性编程:使用 try-catch 包裹 API 调用。
  • 寻找替代方案:例如使用 font.setSkewX() 模拟斜体。
  • 关注官方变更日志:及时更新 SDK 并检查废弃接口。

5.4 图片权限与路径

直接使用相册返回的 uri 读取图片有时会遇到权限问题,或者 Image 组件无法直接加载某些格式的 uri。

解决方案
“Copy to Sandbox” 模式。将选中的图片复制到 context.cacheDir,生成标准的 file:// 路径,既解决了权限问题,也统一了路径格式。这在处理多媒体文件时是一个通用的最佳实践。

6. 运行效果与性能分析

  1. 启动速度:得益于 ArkTS 的 AOT 编译,页面加载几乎是瞬时的。
  2. 内存占用:在处理 4K 图片时,内存峰值控制在合理范围内。手动触发 GC 后,内存迅速回落,说明 pixelMap 的释放机制工作正常。
  3. 交互体验
    • 首页:点击“打开原生水印工具”,流畅跳转至 ArkTS 编写的 Native 页面。
    • 选图:调起系统 PhotoPicker,体验丝滑,无卡顿。
    • 编辑
      • 输入 “Hello Harmony”。
      • 点击红色圆点,文字变红。
      • 点击 “Italic”,文字变斜。
      • 打开“时间戳”开关,自动追加当前时间。
    • 生成:点击“添加水印”,Canvas 绘制耗时通常在 50ms 以内(视图片大小而定),用户感知极快。

运行效果:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

7. 结语与展望

通过这个实战,我们验证了 Kuikly + ArkTS 混合开发的强大潜力:

  • Kuikly 负责跨平台业务,节省 70% 的重复工作量。
  • ArkTS 负责高性能图形处理和系统交互,保证原生级体验。

这种架构模式,为从 Android/iOS 向 HarmonyOS 迁移的开发者提供了一条平滑且高效的路径。未来,随着 Kuikly 对鸿蒙原生能力的进一步封装,我们或许能用 Kotlin DSL 完成更多工作,但在当下,掌握 ArkTS 混合开发无疑是通往鸿蒙生态的最佳门票。

7.1 下一步计划

  • 手势操作:支持水印文字的拖拽、缩放、旋转(需结合 Matrix 变换)。
  • 图片滤镜:引入 OpenGL 或 Native C++ 层,实现更高级的滤镜效果。
  • 批量处理:利用 Worker 线程,实现多图批量加水印功能。

拓展阅读


作者:Goway_Hui
时间:2026-02-02
版权:本文基于 KuiklyUI-mini 开源项目实践撰写。

如果觉得本项目对你有帮助,欢迎点赞收藏!

(本文代码基于 HarmonyOS API 12+ 开发)

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐