引言

在上一篇 KuiklyUI 基础实战中,我们完成了简单 Todo 应用的开发,初步掌握了 Kuikly 跨平台框架的基本使用方法,包括 KMP 共享层搭建、跨端 UI 编写以及多端调试流程。本次实战将在此基础上进一步深入,探索 Kuikly 与 ArkTS 混合开发的核心玩法,从零打造一个支持 Android、HarmonyOS 双端运行的图片水印工具。

不同于纯 Kuikly 跨端开发,混合开发模式能够完美解决 “跨端效率” 与 “原生性能” 的矛盾 —— 用 Kuikly 编写一套跨端业务逻辑,复用率超 70%,无需为不同平台重复编码;用 ArkTS 编写鸿蒙端核心功能,直接调用系统底层 API,实现图片绘制、系统相册交互等高性能操作,让应用体验接近纯原生开发。

本文将以新手视角,详细记录从项目搭建、代码编写、调试运行到问题排查的完整流程,每一步都附带具体操作说明、关键代码及注释,即使是刚接触 Kuikly 和 ArkTS 的新手,也能跟着步骤完成项目开发,避免 “复制粘贴式开发”,真正掌握混合开发的核心技巧。

本次实战目标:开发一个支持双端运行的图片水印工具,实现 “选择图片→输入水印文字→配置水印参数→合成水印→预览效果” 的完整流程,鸿蒙端用 ArkTS 实现原生高性能处理,Android 端复用 Kuikly 代码实现兼容运行,最终达成一套代码多端适配的开发目标。

模板项目地址(KuiklyUI-mini:这是一个基于腾讯官方 Kuikly 模板的精简版项目,剔除了Web、H5、小程序,只留下了Android、iOS和OHOS。保留了核心的渲染与桥接能力,旨在提供一个清晰、轻量级的 HarmonyOS 跨平台开发集成示例。 - AtomGit | GitCode)

项目地址(Kuikly-photo - AtomGit | GitCode)

1. 混合开发核心思路与优势解析(新手必看)

在开始开发前,我们先搞清楚一个核心问题:为什么要采用 Kuikly + ArkTS 混合开发模式?纯 Kuikly 开发或者纯 ArkTS 开发不行吗?

对于新手来说,可能会觉得 “多学一种开发模式增加负担”,但实际上,混合开发是当前跨平台开发的最优解之一,尤其是在鸿蒙生态快速发展的当下,既能降低开发成本,又能保证应用体验,具体优势我们结合本次项目慢慢解析。

1.1 三种开发模式对比(清晰易懂)

为了让大家更直观地理解混合开发的优势,我们将纯 Kuikly 开发、纯 ArkTS 开发、Kuikly + ArkTS 混合开发三种模式做一个详细对比,结合本次图片水印项目的需求,看看哪种模式更适合我们。

对比维度 纯 Kuikly/KMP 开发 纯 ArkTS 开发 Kuikly + ArkTS 混合开发
开发效率 极高:一套代码运行在 Android、iOS、HarmonyOS,无需重复编写业务逻辑,适合快速迭代常规业务 中等:仅针对鸿蒙平台开发,无法复用代码,开发多端应用需要重复编码,效率较低 高:跨端业务逻辑复用(70%+),核心功能单独开发,兼顾效率与体验,适合复杂多端应用
性能表现 中高:基于 Kotlin 编译,接近原生,但调用系统底层 API 时需要通过框架封装,存在一定性能损耗,不适合高性能图形处理 极高:鸿蒙原生开发语言,直接调用系统底层 API,无中间层损耗,适合图片绘制、硬件交互等高性能需求 极高:常规业务用 Kuikly(满足日常性能需求),核心功能(如水印合成)用 ArkTS(原生性能),按需分配,性能最优
系统能力调用 有限:依赖 Kuikly 框架封装的 API,部分系统底层能力(如鸿蒙沙箱文件管理、Drawing 绘图 API)未被封装,无法直接调用 完全覆盖:支持调用鸿蒙所有系统 API,包括底层图形、文件、硬件等能力,功能完整性无可替代 完全覆盖:Kuikly 负责常规业务,ArkTS 负责调用原生 API,通过桥接模块实现两者通信,兼顾通用性与完整性
学习成本 低:只需学习 Kotlin DSL 语法和 Kuikly 框架规则,无需掌握多平台原生开发语言 中高:需要学习 ArkTS 语法、鸿蒙应用开发规范、系统 API 调用等,单独开发多端需要学习多种语言 中等:基于 Kuikly 基础,只需额外学习 ArkTS 核心语法和跨端桥接逻辑,无需掌握完整的多端原生开发
适配本次项目 不适合:水印合成需要调用系统绘图 API 和文件管理能力,Kuikly 封装不足,无法实现高性能水印处理,且体验较差 适合但低效:能实现所有功能,但无法复用代码,Android 端需要重新用 Kotlin/Java 开发,开发成本高 完全适合:Android 端复用 Kuikly 代码,鸿蒙端用 ArkTS 实现核心水印功能,一套代码多端运行,兼顾效率与体验

1.2 本次混合开发的核心架构(图文解析)

结合本次图片水印项目,我们采用 “分层架构 + 跨端桥接” 的混合模式,整体架构分为三层:KMP 共享层、鸿蒙原生层(ArkTS)、Android 兼容层,各层职责清晰,通过桥接模块实现通信,具体架构如下:

  1. KMP 共享层(核心复用层):作为整个项目的 “业务核心”,负责编写跨端通用的业务逻辑、UI 布局、数据模型和跨端桥接调用逻辑,编译后生成中间产物,供 Android 端和鸿蒙端调用。本次项目中,共享层主要实现 “跳转原生水印页面” 的逻辑和 Android 端的基础 UI 布局。
  2. 鸿蒙原生层(ArkTS):作为鸿蒙端的 “核心功能层”,负责编写鸿蒙特有的高性能功能,包括图片选择、沙箱文件管理、水印合成(Drawing API 调用)、原生 UI 布局等,直接调用鸿蒙系统底层 API,保证水印处理的性能和体验。
  3. Android 兼容层:无需额外编写代码,Kuikly 框架会自动将 KMP 共享层的代码编译为 Android 端可运行的代码,实现 Android 端的兼容运行,核心功能(如水印合成)会通过 Kuikly 兼容层模拟实现,满足基本使用需求。
  4. 跨端桥接模块:作为 “通信桥梁”,负责实现 KMP 共享层与鸿蒙原生层(ArkTS)的通信,传递跳转参数和数据,解决 Kuikly 代码无法直接调用 ArkTS 代码的问题,是混合开发的核心关键。

简单来说,整个项目的运行流程是:应用启动后,Android 端直接渲染 KMP 共享层的 UI,点击 “打开原生水印工具” 按钮,触发共享层的桥接逻辑;鸿蒙端启动后,先渲染 KMP 共享层的入口 UI,点击按钮后,通过桥接模块跳转到 ArkTS 编写的原生水印页面,执行图片选择、水印合成等操作。

1.3 新手注意事项(避坑指南)

在开始开发前,有几个新手容易踩的坑,提前跟大家说明,避免后续开发中走弯路:

  1. 混合开发的核心是 “分工明确”:共享层只写通用逻辑,不涉及任何平台特有 API;原生层只写平台特有功能,不重复编写通用逻辑,避免出现代码冗余和冲突。
  2. 鸿蒙端的 “沙箱机制”:鸿蒙系统对文件访问有严格的权限控制,不能直接访问系统相册的文件,必须将选中的图片复制到应用沙箱目录(如 CacheDir)后再进行处理,否则会出现权限报错,这是本次项目的重点也是难点。
  3. 桥接模块的 “参数传递”:KMP 共享层与 ArkTS 之间的参数传递,只能通过 JSON 字符串格式,不能直接传递对象,否则会出现解析报错,后续编写桥接代码时要特别注意。
  4. 环境版本的 “一致性”:Kuikly 框架、JDK、Android Studio、DevEco Studio 的版本必须匹配(推荐 JDK17、Android Studio Hedgehog、DevEco Studio 5.0.0.400、鸿蒙 SDK API11),否则会出现构建失败、调试报错等问题。
  5. 代码编写的 “规范性”:ArkTS 是强类型语言,禁止使用 any 类型,必须明确数据模型;Kuikly 基于 Kotlin DSL,语法要符合 Kotlin 规范,避免出现语法错误导致构建失败。

2. 项目结构解析与改造(核心步骤)

模板项目(KuiklyUI-mini)导入成功后,我们需要先熟悉项目结构,然后根据本次图片水印项目的需求,对项目进行改造,删除冗余模块,新增水印相关的文件和代码。本节将详细解析项目结构,帮助大家理解各文件夹和文件的作用,避免后续改造时误删关键文件。

2.1 模板项目目录结构解析(新手必看)

KuiklyUI-mini 是精简版的 Kuikly 混合开发脚手架,剔除了冗余的 Web、小程序模块,专注于 Android、iOS、HarmonyOS 三大平台的核心能力集成,适合新手入门,其核心目录结构如下(重点解析核心文件夹和文件,冗余文件夹后续会删除):

KuiklyUI-mini-master/  # 项目根目录
├── androidApp/        # Android 宿主工程(核心,Android 端运行入口)
│   ├── src/           
│   │   └── main/      
│   │       ├── AndroidManifest.xml  # Android 配置文件(权限、入口等)
│   │       └── res/   # Android 端资源目录
│   └── build.gradle.kts  # Android 端构建配置文件
├── ohosApp/           # 鸿蒙宿主工程(核心,ArkTS 代码目录)
│   ├── entry/         # 鸿蒙应用入口模块(核心)
│   │   ├── src/
│   │   │   └── main/
│   │   │       ├── ets/  # ArkTS 代码目录(核心,编写原生功能)
│   │   │       │   ├── app.ets  # 鸿蒙应用入口文件
│   │   │       │   ├── pages/  # 页面目录(存放 ArkTS 页面)
│   │   │       │   │   └── Index.ets  # Kuikly 渲染入口页面
│   │   │       │   └── kuikly/
│   │   │       │       └── modules/
│   │   │       │           └── KRBridgeModule.ets  # 跨端桥接模块(需修改)
│   │   │       ├── resources/  # 鸿蒙资源目录
│   │   │       └── config.json  # 鸿蒙应用配置文件(需修改)
│   │   └── build.gradle.kts  # 鸿蒙模块构建配置文件
├── shared/            # KMP 共享层(核心,跨端代码目录)
│   ├── src/           
│   │   ├── commonMain/  # 通用代码目录(所有平台复用)
│   │   │   ├── kotlin/  # Kotlin 代码目录(核心)
│   │   │   │   └── com/
│   │   │   │       └── tencent/
│   │   │   │           └── kuikly/
│   │   │   │               └── demo/
│   │   │   │                   ├── pages/  # 共享层页面目录(新增代码)
│   │   │   │                   └── util/  # 共享层工具类目录
│   │   │   └── resources/  # 共享层资源目录
│   └── build.gradle.kts  # 共享层构建配置文件
└── settings.gradle.kts  # 项目设置文件(模块管理)
2.1.1 核心目录作用详解

对于本次项目来说,核心目录有 3 个:shared(KMP 共享层)、ohosApp(鸿蒙原生层)、androidApp(Android 宿主工程),其他目录可忽略,具体作用如下:

  1. shared 目录(KMP 共享层)

    • 核心作用:编写跨端通用的业务逻辑、UI 布局、数据模型和桥接调用逻辑,编译后生成中间产物,供 Android 端和鸿蒙端调用,是实现 “一套代码多端运行” 的核心。
    • 关键文件夹:commonMain/kotlin(通用 Kotlin 代码目录),后续新增的跨端业务逻辑和 UI 布局均放在此目录。
    • 注意事项:shared 目录中的代码不能涉及任何平台特有 API,只能使用 Kuikly 框架提供的跨端 API 和 Kotlin 标准库,否则会出现编译报错。
  2. ohosApp 目录(鸿蒙原生层)

    • 核心作用:编写鸿蒙端特有代码,包括 ArkTS 页面、原生功能实现(图片选择、水印合成)、桥接模块等,直接调用鸿蒙系统 API,保证鸿蒙端的性能和体验。
    • 关键文件夹:entry/src/main/ets(ArkTS 代码目录),后续新增的原生水印页面和修改的桥接模块均放在此目录。
    • 关键文件:config.json(鸿蒙应用配置文件),需配置页面路由、应用权限等,否则应用无法正常运行。
  3. androidApp 目录(Android 宿主工程)

    • 核心作用:Android 端的运行入口,无需编写额外代码,Kuikly 框架会自动将 shared 目录的代码编译为 Android 端可运行的代码,实现 Android 端的兼容运行。
    • 关键文件:AndroidManifest.xml(Android 配置文件),需配置应用权限(如相册访问权限),否则 Android 端无法正常选择图片。

2.2 项目改造步骤(删除冗余,新增核心文件)

模板项目是 Todo 应用,包含很多与本次水印项目无关的冗余文件和代码,我们需要先删除这些冗余内容,然后新增水印相关的文件和代码,改造步骤如下(新手请严格按照步骤操作,避免误删关键文件):

2.2.1 步骤 1:删除模板冗余文件(核心)

删除冗余文件可以减少项目体积,避免代码冲突,具体删除内容如下:

  1. 删除 shared 目录中的冗余文件

    • 进入 shared/src/commonMain/kotlin/com/tencent/kuikly/demo/pages 目录,删除所有与 Todo 应用相关的文件(如 TodoPage.kt、TodoListUI.kt 等),保留空的 pages 目录。
    • 进入 shared/src/commonMain/kotlin/com/tencent/kuikly/demo 目录,删除与 Todo 应用相关的 util 工具类(如 TodoUtil.kt 等),保留空的 util 目录(后续可用于新增工具类)。
  2. 删除 ohosApp 目录中的冗余文件

    • 进入 ohosApp/entry/src/main/ets/pages 目录,删除除 Index.ets 以外的所有文件(Index.ets 是 Kuikly 渲染入口页面,不能删除)。
    • 进入 ohosApp/entry/src/main/resources 目录,删除与 Todo 应用相关的图片、字符串、布局等资源(如 todo_icon.png、strings.xml 中的 Todo 相关字符串等),保留默认的资源文件。
  3. 删除 androidApp 目录中的冗余文件

    • 进入 androidApp/src/main/res 目录,删除与 Todo 应用相关的图片、布局、字符串等资源(如 todo_icon.png、layout 中的 todo_layout.xml 等),保留默认的资源文件。

注意:删除文件时,一定要确认文件是否与本次项目无关,不要删除核心配置文件(如 build.gradle.kts、config.json、AndroidManifest.xml 等),否则会导致项目无法构建和运行。

2.2.2 步骤 2:新增水印项目核心文件

删除冗余文件后,我们需要新增水印项目相关的核心文件,按照 “共享层→鸿蒙原生层→桥接模块” 的顺序新增,确保文件目录结构正确,具体新增内容如下:

新增 shared 层核心文件(跨端代码)

进入 shared/src/commonMain/kotlin/com/tencent/kuikly/demo/pages 目录,新增 gallery 文件夹(用于存放水印相关的跨端代码)。

gallery 文件夹中,新增 GalleryPage.kt 文件(跨端水印业务逻辑控制器,负责跳转原生水印页面),代码如下:

package com.tencent.kuikly.demo.pages.gallery

import com.tencent.kuikly.Kuikly
import com.tencent.kuikly.page.KuiklyPage

// 跨端水印业务逻辑控制器,继承 KuiklyPage 实现跨端复用
class GalleryPage : KuiklyPage() {
    // 跳转鸿蒙原生水印页面(通过桥接模块调用 ArkTS 方法)
    fun jumpToNativeGallery() {
        // 调用桥接模块的方法,参数以 JSON 字符串格式传递(新手重点注意)
        Kuikly.callNative(
            moduleName = "KRBridgeModule", // 桥接模块名称(与 ArkTS 中一致)
            methodName = "navigateToNativeGallery", // 桥接方法名称
            params = "{}", // 无参数时传递空 JSON,有参数时格式如:"{\"key\":\"value\"}"
            callback = { result ->
                // 桥接调用回调,处理 ArkTS 返回的结果
                if (result.isSuccess) {
                    // 跳转成功逻辑(可添加日志或提示)
                    println("跳转原生水印页面成功")
                } else {
                    // 跳转失败逻辑
                    println("跳转原生水印页面失败:${result.errorMsg}")
                }
            }
        )
    }
}

// 提供 UI 入口,供 Kuikly 框架渲染
fun galleryPage() = GalleryPage().apply {
    ui = GalleryPageUI(this)
}
gallery 文件夹中,新增 GalleryPageUI.kt 文件(跨端 UI 布局,Android 端渲染,鸿蒙端作为入口 UI),代码如下:
package com.tencent.kuikly.demo.pages.gallery

import com.tencent.kuikly.ui.*
import com.tencent.kuikly.ui.container.Column
import com.tencent.kuikly.ui.text.Text
import com.tencent.kuikly.ui.button.Button

// 跨端 UI 布局,接收 GalleryPage 控制器,绑定点击事件
class GalleryPageUI(private val page: GalleryPage) : KuiklyUI() {
    override fun build() = Column(
        // 布局属性:居中对齐、占满屏幕、内边距
        modifier = Modifier
            .fillMaxSize()
            .justifyContent(JustifyContent.Center)
            .alignItems(AlignItems.Center)
            .padding(20.dp)
    ) {
        // 标题文本
        Text(
            text = "图片水印工具",
            modifier = Modifier.padding(bottom = 30.dp),
            style = TextStyle(
                fontSize = 24.sp,
                fontWeight = FontWeight.Bold
            )
        )
        
        // 跳转原生水印页面按钮
        Button(
            text = "打开原生水印工具",
            modifier = Modifier
                .width(200.dp)
                .height(50.dp),
            onClick = {
                // 点击触发控制器的跳转方法
                page.jumpToNativeGallery()
            }
        )
    }
}

新增鸿蒙原生层核心文件(ArkTS 代码)

  • 进入 ohosApp/entry/src/main/ets/pages 目录,新增 NativeGalleryPage.ets 文件(鸿蒙原生水印页面,核心文件,实现图片选择、水印合成等功能),代码如下(附带详细注释):
// 鸿蒙原生水印页面,基于 ArkTS 开发,调用系统原生 API
import router from '@ohos.router';
import picker from '@ohos.file.picker';
import fileio from '@ohos.fileio';
import image from '@ohos.multimedia.image';
import drawing from '@ohos.graphics.drawing';
import abilityAccessCtrl from '@ohos.abilityAccessCtrl';
import bundle from '@ohos.bundle';

// 定义水印参数接口(强类型,禁止 any,新手重点注意)
interface WatermarkParams {
    text: string; // 水印文字
    fontSize: number; // 字体大小
    color: string; // 字体颜色(十六进制,如 #000000)
    opacity: number; // 透明度(0-1)
}

@Entry
@Component
struct NativeGalleryPage {
    // 状态管理:选中的图片路径(沙箱目录路径)
    @State selectedImagePath: string = '';
    // 状态管理:水印参数
    @State watermarkParams: WatermarkParams = {
        text: '我的水印',
        fontSize: 30,
        color: '#000000',
        opacity: 0.5
    };
    // 状态管理:合成后的水印图片
    @State watermarkedImage: image.PixelMap | null = null;

    // 页面构建
    build() {
        Column({ space: 20 }) {
            // 标题
            Text('原生水印工具')
                .fontSize(24)
                .fontWeight(FontWeight.Bold)
                .width('100%')
                .textAlign(TextAlign.Center)
                .padding({ top: 20, bottom: 10 });

            // 图片选择按钮
            Button('选择图片')
                .width('80%')
                .height(50)
                .onClick(() => {
                    this.selectImage(); // 触发图片选择
                });

            // 选中图片预览
            if (this.selectedImagePath !== '') {
                Image(this.selectedImagePath)
                    .width('80%')
                    .height(300)
                    .objectFit(ImageFit.Contain)
                    .border({ width: 1, color: '#EEEEEE' });
            } else {
                // 未选择图片时的占位
                Text('未选择图片')
                    .width('80%')
                    .height(300)
                    .border({ width: 1, color: '#EEEEEE' })
                    .textAlign(TextAlign.Center)
                    .backgroundColor('#F5F5F5');
            }

            // 水印文字输入框
            TextInput({ placeholder: '请输入水印文字', text: this.watermarkParams.text })
                .width('80%')
                .height(50)
                .padding(10)
                .border({ width: 1, color: '#EEEEEE' })
                .onChange((value) => {
                    this.watermarkParams.text = value; // 实时更新水印文字
                });

            // 合成水印按钮
            Button('合成水印')
                .width('80%')
                .height(50)
                .backgroundColor('#007AFF')
                .fontColor('#FFFFFF')
                .onClick(() => {
                    if (this.selectedImagePath === '') {
                        this.showToast('请先选择图片');
                        return;
                    }
                    this.generateWatermark(); // 触发水印合成
                });

            // 合成后的水印图片预览
            if (this.watermarkedImage !== null) {
                Image(this.watermarkedImage)
                    .width('80%')
                    .height(300)
                    .objectFit(ImageFit.Contain)
                    .border({ width: 1, color: '#EEEEEE' });

                // 保存水印图片按钮
                Button('保存到相册')
                    .width('80%')
                    .height(50)
                    .onClick(() => {
                        this.saveWatermarkedImage(); // 保存图片到相册
                    });
            }
        }
        .width('100%')
        .height('100%')
        .backgroundColor('#FFFFFF')
    }

    // 1. 选择图片(调用系统相册,复制到应用沙箱目录)
    private async selectImage() {
        try {
            // 1.1 申请相册读取权限
            const atManager = abilityAccessCtrl.createAtManager();
            const bundleName = await bundle.getBundleInfoForSelf(bundle.BundleFlag.GET_BUNDLE_INFO_DEFAULT).then(info => info.name);
            const tokenId = await atManager.getTokenIdByBundleName(bundleName);
            const permissions = ['ohos.permission.READ_IMAGEVIDEO'];
            const result = await atManager.requestPermissionsFromUser(tokenId, permissions);
            
            if (result.grantResults[0] !== 0) {
                this.showToast('请授予相册读取权限');
                return;
            }

            // 1.2 打开系统相册选择图片
            const photoPicker = new picker.PhotoViewPicker();
            const selectResult = await photoPicker.select();

            // 1.3 获取选中图片的 URI,复制到应用沙箱目录(鸿蒙沙箱机制要求)
            if (selectResult.photoUris.length > 0) {
                const sourceUri = selectResult.photoUris[0];
                // 应用沙箱缓存目录路径
                const cacheDir = this.context.cacheDir;
                const targetPath = `${cacheDir}/selected_image_${Date.now()}.png`;

                // 1.4 复制文件到沙箱目录
                await this.copyFile(sourceUri, targetPath);
                this.selectedImagePath = targetPath; // 更新选中图片路径
                this.watermarkedImage = null; // 重置合成后的图片
            }
        } catch (error) {
            console.error('选择图片失败:', error);
            this.showToast('选择图片失败,请重试');
        }
    }

    // 2. 复制文件(从系统相册到应用沙箱,解决鸿蒙沙箱权限问题)
    private async copyFile(sourceUri: string, targetPath: string) {
        const file = await fileio.open(sourceUri, fileio.OpenMode.READ_ONLY);
        const targetFile = await fileio.open(targetPath, fileio.OpenMode.WRITE_ONLY | fileio.OpenMode.CREATE);
        
        const buffer = new ArrayBuffer(4096);
        let bytesRead = 0;
        do {
            bytesRead = await fileio.read(file.fd, buffer);
            if (bytesRead > 0) {
                await fileio.write(targetFile.fd, buffer.slice(0, bytesRead));
            }
        } while (bytesRead > 0);
        
        await fileio.close(file.fd);
        await fileio.close(targetFile.fd);
    }

    // 3. 合成水印(调用 Drawing API,原生高性能绘制)
    private async generateWatermark() {
        try {
            // 3.1 读取沙箱中的图片,转换为 PixelMap
            const imageSource = image.createImageSource(this.selectedImagePath);
            const pixelMap = await imageSource.createPixelMap();

            // 3.2 创建 Drawing 画布,与图片尺寸一致
            const width = pixelMap.getImageInfo().size.width;
            const height = pixelMap.getImageInfo().size.height;
            const surface = drawing.Surface.create(width, height);
            const canvas = surface.getCanvas();

            // 3.3 绘制原图到画布
            const paint = new drawing.Paint();
            canvas.drawPixelMap(pixelMap, 0, 0, paint);

            // 3.4 配置水印画笔(字体、颜色、透明度)
            const watermarkPaint = new drawing.Paint();
            watermarkPaint.setColor(drawing.Color.parseColor(this.watermarkParams.color));
            watermarkPaint.setTextSize(this.watermarkParams.fontSize);
            watermarkPaint.setAlpha(Math.floor(this.watermarkParams.opacity * 255)); // 透明度转换(0-255)

            // 3.5 计算水印位置(右下角,距边缘 20px)
            const textBounds = new drawing.Rect();
            watermarkPaint.getTextBounds(this.watermarkParams.text, textBounds);
            const textX = width - textBounds.width - 20;
            const textY = height - textBounds.height - 20;

            // 3.6 绘制水印文字到画布
            canvas.drawText(this.watermarkParams.text, textX, textY, watermarkPaint);

            // 3.7 保存画布,转换为 PixelMap(用于预览)
            const watermarkedPixelMap = await surface.makePixelMap();
            this.watermarkedImage = watermarkedPixelMap;

            // 3.8 释放资源
            surface.release();
            pixelMap.release();
        } catch (error) {
            console.error('合成水印失败:', error);
            this.showToast('合成水印失败,请重试');
        }
    }

    // 4. 保存水印图片到相册
    private async saveWatermarkedImage() {
        try {
            if (this.watermarkedImage === null) return;

            // 4.1 申请相册写入权限
            const atManager = abilityAccessCtrl.createAtManager();
            const bundleName = await bundle.getBundleInfoForSelf(bundle.BundleFlag.GET_BUNDLE_INFO_DEFAULT).then(info => info.name);
            const tokenId = await atManager.getTokenIdByBundleName(bundleName);
            const permissions = ['ohos.permission.WRITE_IMAGEVIDEO'];
            const result = await atManager.requestPermissionsFromUser(tokenId, permissions);
            
            if (result.grantResults[0] !== 0) {
                this.showToast('请授予相册写入权限');
                return;
            }

            // 4.2 将 PixelMap 保存为图片文件
            const cacheDir = this.context.cacheDir;
            const savePath = `${cacheDir}/watermarked_image_${Date.now()}.png`;
            const file = await fileio.open(savePath, fileio.OpenMode.WRITE_ONLY | fileio.OpenMode.CREATE);
            
            // 4.3 编码为 PNG 格式,写入文件
            const imagePacker = image.createImagePacker();
            const packOptions = { format: image.ImageFormat.PNG };
            const packedData = await imagePacker.packing(this.watermarkedImage, packOptions);
            await fileio.write(file.fd, packedData);
            await fileio.close(file.fd);

            // 4.4 将文件复制到系统相册
            const photoPicker = new picker.PhotoViewPicker();
            await photoPicker.save(savePath);
            this.showToast('保存成功');
        } catch (error) {
            console.error('保存图片失败:', error);
            this.showToast('保存图片失败,请重试');
        }
    }

    // 辅助方法:显示提示 Toast
    private showToast(message: string) {
        promptAction.showToast({
            message: message,
            duration: 2000
        });
    }
}
  1. 准备修改的核心文件(后续补充完整代码)ohosApp/entry/src/main/ets/kuikly/modules/KRBridgeModule.ets(跨端桥接模块,实现共享层与 ArkTS 代码的通信)
  2. ohosApp/entry/src/main/config.json(鸿蒙应用配置文件,配置页面路由和应用权限)
  3. androidApp/src/main/AndroidManifest.xml(Android 配置文件,配置应用权限)
2.2.3 改造后项目目录结构(核心部分)

改造完成后,项目的核心目录结构如下(只展示关键文件和文件夹):

KuiklyUI-mini-master/
├── androidApp/
│   └── src/main/
│       ├── AndroidManifest.xml  # 已修改(配置权限)
│       └── res/  # 已清理冗余资源
├── ohosApp/
│   └── entry/src/main/
│       ├── ets/
│       │   ├── pages/
│       │   │   ├── Index.ets  # 保留(Kuikly 渲染入口)
│       │   │   └── NativeGalleryPage.ets  # 新增(原生水印页面)
│       │   └── kuikly/modules/
│       │       └── KRBridgeModule.ets  # 已修改(桥接模块)
│       ├── config.json  # 已修改(配置路由和权限)
│       └── resources/  # 已清理冗余资源
└── shared/
    └── src/commonMain/kotlin/com/tencent/kuikly/demo/
        └── pages/
            └── gallery/
                ├── GalleryPage.kt  # 新增(跨端业务逻辑)
                └── GalleryPageUI.kt  # 新增(跨端 UI 布局)

2.3 关键配置文件修改(必做,否则无法运行)

新增核心文件后,我们需要修改关键配置文件,配置页面路由、应用权限等,否则应用无法正常运行,下面详细说明各配置文件的修改步骤及完整代码:

2.3.1 鸿蒙配置文件:config.json(核心)

config.json 是鸿蒙应用的核心配置文件,负责配置应用的基本信息、页面路由、应用权限等,完整修改后代码如下(替换原文件内容):

{
  "app": {
    "bundleName": "com.tencent.kuikly.demo",
    "vendor": "tencent",
    "versionCode": 1000000,
    "versionName": "1.0.0",
    "minAPIVersion": 11
  },
  "module": {
    "package": "com.tencent.kuikly.demo.entry",
    "name": ".entry",
    "mainAbility": "com.tencent.kuikly.demo.entry.MainAbility",
    "deviceTypes": [
      "phone"
    ],
    "distro": {
      "deliveryWithInstall": true,
      "moduleName": "entry",
      "moduleType": "entry"
    },
    "abilities": [
      {
        "name": "com.tencent.kuikly.demo.entry.MainAbility",
        "icon": "$media:icon",
        "label": "图片水印工具",
        "description": "$string:mainability_description",
        "skills": [
          {
            "entities": [
              "entity.system.home"
            ],
            "actions": [
              "action.system.home"
            ]
          }
        ],
        "orientation": "portrait",
        "launchType": "standard"
      }
    ],
    "pages": [
      "pages/Index",
      "pages/NativeGalleryPage"
    ],
    "reqPermissions": [
      {
        "name": "ohos.permission.READ_IMAGEVIDEO",
        "reason": "需要访问相册,选择图片进行水印处理",
        "usedScene": {
          "ability": [
            "com.tencent.kuikly.demo.entry.MainAbility"
          ],
          "when": "always"
        }
      },
      {
        "name": "ohos.permission.WRITE_IMAGEVIDEO",
        "reason": "需要保存处理后的水印图片到相册",
        "usedScene": {
          "ability": [
            "com.tencent.kuikly.demo.entry.MainAbility"
          ],
          "when": "always"
        }
      }
    ],
    "apiVersion": {
      "compatible": 11,
      "target": 11,
      "releaseType": "Release"
    }
  }
}

关键修改说明

  1. pages 节点:新增 NativeGalleryPage 页面路由,确保能跳转到原生水印页面;
  2. reqPermissions 节点:配置相册读取(READ_IMAGEVIDEO)和写入(WRITE_IMAGEVIDEO)权限,删除可选的 MEDIA_LOCATION 权限,简化配置;
  3. apiVersion 节点:确保 compatibletarget 均为 11,否则无法使用 Drawing API 和沙箱文件管理 API;
  4. label 节点:修改应用名称为 “图片水印工具”,贴合项目需求。
2.3.2 桥接模块:KRBridgeModule.ets(核心,跨端通信)

修改 ohosApp/entry/src/main/ets/kuikly/modules/KRBridgeModule.ets 文件,实现共享层(Kotlin)与鸿蒙原生层(ArkTS)的通信,完整代码如下(替换原文件内容):

// 跨端桥接模块,用于 Kuikly 共享层与 ArkTS 原生层的通信
import router from '@ohos.router';
import { KuiklyNativeModule, registerNativeModule } from '@tencent/kuikly';

// 定义桥接模块,继承 KuiklyNativeModule
export class KRBridgeModule extends KuiklyNativeModule {
    // 构造函数,指定模块名称(必须与共享层调用时的 moduleName 一致)
    constructor() {
        super('KRBridgeModule');
    }

    // 跳转原生水印页面的桥接方法(必须与共享层调用时的 methodName 一致)
    // params:共享层传递的 JSON 字符串参数,callback:回调函数,返回执行结果
    navigateToNativeGallery(params: string, callback: (result: { isSuccess: boolean; errorMsg?: string }) => void) {
        try {
            // 解析共享层传递的参数(本次无参数,直接跳转)
            const paramsObj = JSON.parse(params);
            console.log('共享层传递的参数:', paramsObj);

            // 跳转至原生水印页面(使用鸿蒙路由)
            router.pushUrl({
                url: 'pages/NativeGalleryPage' // 页面路由,与 config.json 中一致
            }).then(() => {
                // 跳转成功,回调通知共享层
                callback({ isSuccess: true });
            }).catch((error) => {
                // 跳转失败,回调通知共享层(携带错误信息)
                console.error('跳转原生页面失败:', error);
                callback({
                    isSuccess: false,
                    errorMsg: `跳转失败:${error.message}`
                });
            });
        } catch (error) {
            // 参数解析失败(如 JSON 格式错误),回调通知共享层
            console.error('参数解析失败:', error);
            callback({
                isSuccess: false,
                errorMsg: `参数解析失败:${error.message}`
            });
        }
    }
}

// 注册桥接模块,确保 Kuikly 共享层能调用到
registerNativeModule(KRBridgeModule);

关键说明

  1. 模块名称(KRBridgeModule)、方法名称(navigateToNativeGallery)必须与共享层 GalleryPage.kt 中调用的一致,否则无法通信;
  2. params 参数需用 JSON.parse 解析(共享层传递的是 JSON 字符串),即使无参数也要解析,避免报错;
  3. 路由跳转使用鸿蒙原生 router.pushUrl 方法,url 必须与 config.jsonpages 节点的路由一致。
2.3.3 Android 配置文件:AndroidManifest.xml

修改 androidApp/src/main/AndroidManifest.xml 文件,配置 Android 端相册访问权限,完整代码如下(替换原文件内容):

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.tencent.kuikly.demo">

    <!-- Android 端权限配置:相册读取和写入权限 -->
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <!-- Android 13 及以上版本,需额外添加媒体文件权限 -->
    <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />

    <application
        android:name=".MyApplication"
        android:icon="@mipmap/ic_launcher"
        android:label="图片水印工具"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.AppCompat.Light.NoActionBar">

        <activity
            android:name="com.tencent.kuikly.activity.KuiklyMainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

关键说明

  1. 配置 READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE 权限,适配 Android 12 及以下版本;
  2. 配置 READ_MEDIA_IMAGES 权限,适配 Android 13 及以上版本,避免权限报错;
  3. 修改 applicationlabel 为 “图片水印工具”,与鸿蒙端应用名称保持一致。

3. 代码调试与运行(新手必看)

核心代码和配置文件修改完成后,即可进行双端调试运行,确保应用能正常工作,步骤如下(附带常见报错解决方法):

3.1华为云真机测试(进阶操作)

在模拟器测试通过后,我们可以通过华为云真机进行更真实的环境测试,步骤如下:

  1. 准备签名文件:在 DevEco Studio 中,进入 “Build→Generate Signed Bundle/APK”,生成鸿蒙应用的签名文件(.p12);
  2. 配置云真机:登录华为云官网,进入 “云调试” 页面,选择需要测试的鸿蒙真机型号(如 Mate 70 Pro);
  3. 上传应用包:将 DevEco Studio 生成的 HAP 包上传至云真机,点击 “安装” 按钮;
  4. 功能测试:在云真机上打开应用,重复 “选择图片→合成水印→保存相册” 的流程,验证功能在真机环境下的稳定性。

这里我选了一个魔丸,哈哈

4. 功能拓展与优化建议(进阶学习)

基础版的图片水印工具实现后,我们可以根据实际需求进行功能拓展和优化,提升应用的实用性和用户体验,以下是一些推荐的拓展方向:

4.1 功能拓展方向

  1. 水印参数自定义:新增水印字体选择、旋转角度调节、间距设置等功能,让用户可以更灵活地调整水印样式;
  2. 批量水印处理:支持选择多张图片进行批量水印合成,提升处理效率;
  3. 图片编辑功能:新增图片裁剪、旋转、滤镜等基础编辑功能,丰富应用的使用场景;
  4. 水印模板功能:预设多种水印模板(如日期水印、二维码水印),用户可以一键应用。

4.2 性能优化建议

  1. 图片压缩处理:在合成水印前,对选中的图片进行压缩处理,减少内存占用,提升合成速度;
  2. 异步处理优化:将图片复制、水印合成等耗时操作放在子线程中执行,避免主线程阻塞导致的界面卡顿;
  3. 资源释放优化:在 generateWatermark 方法中,及时释放 PixelMapSurface 等资源,避免内存泄漏;
  4. 缓存策略优化:对合成后的水印图片进行本地缓存,用户再次查看时无需重新合成。

4.3 兼容性优化建议

  1. Android 端功能补齐:在 Android 端也实现原生的水印合成功能(使用 Kotlin 调用 Android 系统的 Canvas API),提升 Android 端的用户体验;
  2. 多分辨率适配:针对不同屏幕尺寸和分辨率的设备,优化水印的位置和大小,确保水印显示效果一致;
  3. 版本兼容处理:在代码中添加版本判断逻辑,对低版本鸿蒙系统(低于 API11)进行降级处理,避免应用崩溃。

5. 总结与展望

本次实战我们基于 KuiklyUI-mini 模板,采用 Kuikly + ArkTS 混合开发模式,成功实现了一个支持 Android 和 HarmonyOS 双端运行的图片水印工具。通过本次开发,我们不仅掌握了 Kuikly 跨平台框架的基本使用方法,还深入理解了混合开发的核心思想 ——“跨端复用 + 原生增强”,即在保证跨端开发效率的同时,通过原生代码提升应用的性能和功能完整性。

从技术层面来看,本次项目的核心难点在于鸿蒙沙箱机制的适配和跨端桥接模块的实现。通过将系统相册的图片复制到应用沙箱目录,我们解决了鸿蒙系统的文件访问权限问题;通过 JSON 字符串参数传递和桥接模块注册,我们实现了 Kuikly 共享层与 ArkTS 原生层的通信。这些技术点不仅适用于图片水印工具,也适用于其他需要调用系统底层 API 的跨平台应用开发。

展望未来,随着鸿蒙生态的不断发展,Kuikly 等跨平台框架的功能会越来越完善,混合开发模式也会成为跨平台应用开发的主流选择。作为开发者,我们需要不断学习和掌握新的技术,提升自己的跨平台开发能力,为用户打造更优质的应用体验。

最后,希望本次实战记录能够帮助到正在学习 Kuikly 和 ArkTS 的新手开发者,也欢迎大家在评论区分享自己的开发经验和问题,一起交流学习,共同进步!

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net/

Logo

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

更多推荐