HBuilderX

HBuilderX

极客开发工具
uni-app

uni-app

开发一次,多端覆盖
uniCloud

uniCloud

云开发平台
HTML5+

HTML5+

增强HTML5的功能体验
MUI

MUI

上万Star的前端框架

【鸿蒙征文】从零实现 uni-app 鸿蒙平台 TTS 插件:UTS 开发实践指南

鸿蒙征文

从零实现 uni-app 鸿蒙平台 TTS 插件:UTS 开发实践指南

随着移动应用的日益普及,文本转语音(TTS)功能已经成为提升用户体验的重要组成部分。在跨平台开发中,我们常常需要为不同平台提供一致的语音合成能力。

uni-app框架在安卓和IOS提供speech能力,但在鸿蒙(HarmonyOS)平台上,不提供speech支持。本文将详细介绍如何通过UTS技术开发一个鸿蒙平台的TTS插件,弥补这一功能空白,并学习如何实现跨平台接口的一致性。

一、项目概述

本项目将开发一个名为Lime TTS的基于UTS(Uni TypeScript)的文本转语音插件,主要目标是为uni-app在鸿蒙平台上提供语音合成能力。通过本项目的学习,我们将掌握如何使用UTS技术调用鸿蒙原生API,实现跨平台插件开发的核心技能。

二、项目目录结构

我们的Lime TTS项目采用标准的uni-app UTS插件结构,清晰地分离了接口定义和平台实现。这种结构设计有助于我们组织代码,并确保跨平台实现的一致性。项目的目录结构如下:

uni_modules/  
└── lime-tts/  
    ├── changelog.md  
    ├── package.json  
    ├── readme.md  
    └── utssdk/  
        ├── app-android/  
        │   ├── config.json  
        │   └── index.uts  
        ├── app-harmony/  
        │   ├── config.json  
        │   └── index.uts  
        ├── app-ios/  
        │   ├── config.json  
        │   └── index.uts  
        ├── interface.uts  
        ├── unierror.uts  
        └── web/  
            └── index.uts
  • utssdk/interface.uts:定义了插件的公共接口和类型,是插件的核心规范文件。
  • utssdk/app-harmony/index.uts:鸿蒙平台的具体实现代码。

三、接口设计与定义

3.1 核心接口和类型设计

Lime TTS 插件通过 uni_modules/lime-tts/utssdk/interface.uts 文件定义了一套完整的类型和接口规范,确保了良好的类型安全性和代码提示。

语音合成选项

export type SpeakOptions = {  
    /**  
     * 语速  
     * 可选,支持范围 [0.5-2],默认值为 1  
     */  
    speed ?: number;  
    /**  
     * 音量  
     * 可选,支持范围 [0-2],默认值为 1  
     */  
    volume ?: number;  
    /**  
     * 音调  
     * 可选,支持范围 [0.5-2],默认值为 1  
     */  
    pitch ?: number;  
    /**  
     * 语境,播放阿拉伯数字用的语种  
     * 可选,支持 "zh-CN" 中文与 "en-US" 英文,默认 "zh-CN"  
     */  
    language ?: Language;  
    // 其他配置项...  
}

音色信息

export type VoiceInfo = {  
    language : Language;  
    person : number;  
    name : string;  
    gender : 'male' | 'female';  
    description : string;  
    status : 'available' | 'downloadable' | 'unavailable';  
}

语音状态

export type SpeechStatus = 'idle' | 'speaking' | 'paused' | 'uninitialized';

3.2 主要功能接口

export interface LimeTTS {  
    /**  
     * 朗读指定文本  
     * @param text 要朗读的文本  
     * @param options 可选参数,如语音ID、语速等  
     */  
    speak(text : string) : void;  
    speak(text : string, options : SpeakOptions | null) : void;  

    /**  
     * 停止当前朗读  
     */  
    stop() : void;  

    /**  
     * 暂停当前朗读  
     */  
    pause() : void;  

    /**  
     * 获取可用语音列表  
     */  
    getVoices() : Promise<VoiceInfo[]>;  

    /**  
     * 获取当前语音状态  
     */  
    getStatus() : SpeechStatus;  

    /**  
     * 销毁TTS实例,释放资源  
     */  
    destroy() : void;  

    /**  
     * 监听TTS事件  
     * @param event 事件类型  
     * @param callback 回调函数  
     */  
    on(event : 'start' | 'end' | 'error' | 'stop', callback : SpeechCallback) : void;  
    off(event : 'start' | 'end' | 'error' | 'stop') : void;  
    off(event : 'start' | 'end' | 'error' | 'stop', callback : SpeechCallback | null) : void;  
}

四、鸿蒙平台实现详解

在本节中,我们将详细学习如何为鸿蒙平台实现TTS功能。我们将通过调用鸿蒙原生的@kit.CoreSpeechKit来实现语音合成,并遵循之前设计的接口规范。

4.0 模块导入

在鸿蒙平台实现中,首先需要导入鸿蒙系统提供的核心语音合成模块:

// 导入鸿蒙语音合成相关模块  
import { textToSpeech } from '@kit.CoreSpeechKit';  
import { BusinessError } from '@kit.BasicServicesKit';  
// 导入公共接口定义  
import { LimeTTS, SpeakOptions, VoiceInfo, SpeechStatus, SpeechCallback } from '../interface.uts';

4.1 核心实现类

uni_modules/lime-tts/utssdk/app-harmony/index.uts文件中,定义了LimeTTSImpl类,该类是鸿蒙平台上TTS功能的核心实现:

class LimeTTSImpl implements LimeTTS {  
    private engine : textToSpeech.TextToSpeechEngine | null = null;  
    private currentVoice : textToSpeech.CreateEngineParams | null = null;  
    private eventListeners : Map<string, Array<SpeechCallback>> = new Map<string, Array<SpeechCallback>>();  
    private isSpeaking : boolean = false;  
    private isPaused : boolean = false;  
    // ...  
}

4.2 引擎初始化

在构造函数中,插件会初始化 TTS 引擎并设置相关回调:

private initialize(options : CreateEngineParams | null) {  
    // 清理旧引擎  
    if (this.engine) {  
        this.engine.shutdown();  
    }  
    const params : textToSpeech.CreateEngineParams = {  
        language: options?.language ?? 'zh-CN',  
        person: options?.person ?? 0,  
        online: 1  
    }  
    this.currentVoice = params  
    const _this = this  
    textToSpeech.createEngine(params).then((res : textToSpeech.TextToSpeechEngine) => {  
        this.engine = res  
        // 设置speak的回调信息  
        let speakListener : textToSpeech.SpeakListener = {  
            // 开始播报回调  
            onStart(requestId : string, response : textToSpeech.StartResponse) {  
                _this.isSpeaking = true;  
                _this.isPaused = false;  
                _this.emit('start', { type: 'start' })  
            },  
            // 其他回调...  
        };  
        this.engine.setListener(speakListener);  
    }).catch((err : BusinessError) => {  
        console.error(`Failed to create engine. Code: ${err.code}, message: ${err.message}.`);  
    });  
}

4.3 文本朗读功能

speak(text : string) : void  
speak(text : string, options : SpeakOptions | null = null) {  
    const extraParams : ESObject = {  
        speed: options?.speed ?? 1,  
        volume: options?.volume ?? 1,  
        pitch: options?.pitch ?? 1,  
        languageContext: options?.language ?? 'zh-CN'  
    } as ESObject  

    this.engine?.speak(text, {  
        requestId: this.generateRequestId(),  
        extraParams  
    } as textToSpeech.SpeakParams);  
}

4.4 音色管理

插件实现了音色列表获取和映射功能,将鸿蒙系统的原生音色信息转换为统一格式:

async getVoices() : Promise<VoiceInfo[]> {  
    try {  
        if (this.engine) {  
            // 使用引擎实例查询  
            const queryParams : textToSpeech.VoiceQuery = {  
                requestId: this.generateRequestId('voice_query'),  
                online: 1  
            };  

            const voices = await this.engine.listVoices(queryParams);  
            return this.mapToVoiceInfo(voices);  
        } else {  
            // 使用全局方法查询  
            const queryParams : textToSpeech.VoiceQuery = {  
                requestId: this.generateRequestId('voice_query'),  
                online: 1  
            };  

            const voices = await textToSpeech.listVoices(queryParams);  
            return this.mapToVoiceInfo(voices);  
        }  
    } catch (error) {  
        console.error('Failed to get voices:', error);  
        return Promise.resolve([])  
    }  
}  

private mapToVoiceInfo(voices : textToSpeech.VoiceInfo[]) : VoiceInfo[] {  
    return voices.map(voice => {  
        // 根据 person 值映射音色名称  
        let name = 'Unknown';  
        let gender : 'male' | 'female' = 'female';  

        switch (voice.person) {  
            case 0:  
            case 13:  
                name = '聆小珊';  
                gender = 'female';  
                break;  
            case 21:  
                name = '凌飞哲';  
                gender = 'male';  
                break;  
            case 8:  
                name = 'Laura';  
                gender = 'female';  
                break;  
        }  

        return {  
            language: voice.language.replace('_', '-'),  
            person: voice.person,  
            name: name,  
            gender: gender,  
            description: voice.description || `${name} ${gender === 'male' ? '男声' : '女声'}`,  
            status: voice.status === 'INSTALLED' ? 'available' :  
                voice.status === 'GA' ? 'downloadable' : 'unavailable'  
        } as VoiceInfo;  
    });  
}

五、测试与验证

在开发完成插件后,我们需要编写测试代码来验证功能是否正常工作。以下是一个用于测试Lime TTS插件的代码示例:

// 导入我们开发的 TTS 工具函数  
import { useTTS } from '@/uni_modules/lime-tts'  

// 创建 TTS 实例 - 测试实例化功能  
const tts = useTTS();  

// 设置事件监听 - 测试事件机制  
// 播放开始事件  
const onStart = (event) => {  
    console.log('语音播放开始');  
};  

// 播放结束事件  
const onEnd = (event) => {  
    console.log('语音播放结束');  
};  

// 错误处理事件  
const onError = (event) => {  
    console.error('语音播放错误:', event);  
};  

// 注册事件监听器  
tts.on('start', onStart);  
tts.on('end', onEnd);  
tts.on('error', onError);  

// 测试核心功能 - 文本朗读  
tts.speak('欢迎使用 Lime TTS 插件!', {  
    speed: 1.0,  // 语速  
    volume: 1.0, // 音量  
    pitch: 1.0   // 音调  
});  

// 测试控制功能  
setTimeout(() => {  
    // 测试暂停功能  
    tts.pause();  
}, 3000);  

// 测试状态查询功能  
const status = tts.getStatus();  
console.log('当前语音状态:', status);  

// 测试音色管理功能  
tts.getVoices().then(voices => {  
    console.log('可用音色列表:', voices);  
});  

// 测试资源释放 - 在组件卸载时调用  
const cleanup = () => {  
    // 移除事件监听器  
    tts.off('start', onStart);  
    tts.off('end', onEnd);  
    tts.off('error', onError);  
    // 销毁实例,释放系统资源  
    tts.destroy();  
};

六、鸿蒙平台特别说明

在鸿蒙平台上,Lime TTS 插件支持以下音色:

  • 中文女声:聆小珊(person: 13,推荐使用)
  • 中文男声:凌飞哲(person: 21,需要下载)
  • 英文女声:Laura(person: 8,需要下载)

七、总结

通过本文的学习,我们详细了解了如何使用UTS技术为uni-app开发鸿蒙平台的TTS插件。这个过程涵盖了从接口定义、模块导入到核心功能实现的完整开发流程,展示了如何通过调用鸿蒙原生的@kit.CoreSpeechKit能力,弥补uni-app在鸿蒙平台上speech API缺失的问题。

开发鸿蒙平台插件的关键要点包括:

  1. 设计统一的跨平台接口,确保API一致性
  2. 正确导入鸿蒙系统模块,如@kit.CoreSpeechKit@kit.BasicServicesKit
  3. 实现核心功能类,处理平台特定的API细节
  4. 封装事件监听机制,提供良好的开发者体验
  5. 处理音色映射和状态管理,确保功能完整性

希望本文能为您学习如何开发uni-app鸿蒙插件提供有价值的参考,帮助您掌握UTS开发技术,为uni-app生态贡献更多优质的平台适配插件。

目前,本插件已上传至uni-app插件市场,全面支持uni-app和uni-appx框架,并兼容安卓、鸿蒙和Web三大平台,iOS平台将在稍晚上线。开发者可以直接在项目中集成使用,体验跨平台的TTS功能。lime-tts

继续阅读 »

从零实现 uni-app 鸿蒙平台 TTS 插件:UTS 开发实践指南

随着移动应用的日益普及,文本转语音(TTS)功能已经成为提升用户体验的重要组成部分。在跨平台开发中,我们常常需要为不同平台提供一致的语音合成能力。

uni-app框架在安卓和IOS提供speech能力,但在鸿蒙(HarmonyOS)平台上,不提供speech支持。本文将详细介绍如何通过UTS技术开发一个鸿蒙平台的TTS插件,弥补这一功能空白,并学习如何实现跨平台接口的一致性。

一、项目概述

本项目将开发一个名为Lime TTS的基于UTS(Uni TypeScript)的文本转语音插件,主要目标是为uni-app在鸿蒙平台上提供语音合成能力。通过本项目的学习,我们将掌握如何使用UTS技术调用鸿蒙原生API,实现跨平台插件开发的核心技能。

二、项目目录结构

我们的Lime TTS项目采用标准的uni-app UTS插件结构,清晰地分离了接口定义和平台实现。这种结构设计有助于我们组织代码,并确保跨平台实现的一致性。项目的目录结构如下:

uni_modules/  
└── lime-tts/  
    ├── changelog.md  
    ├── package.json  
    ├── readme.md  
    └── utssdk/  
        ├── app-android/  
        │   ├── config.json  
        │   └── index.uts  
        ├── app-harmony/  
        │   ├── config.json  
        │   └── index.uts  
        ├── app-ios/  
        │   ├── config.json  
        │   └── index.uts  
        ├── interface.uts  
        ├── unierror.uts  
        └── web/  
            └── index.uts
  • utssdk/interface.uts:定义了插件的公共接口和类型,是插件的核心规范文件。
  • utssdk/app-harmony/index.uts:鸿蒙平台的具体实现代码。

三、接口设计与定义

3.1 核心接口和类型设计

Lime TTS 插件通过 uni_modules/lime-tts/utssdk/interface.uts 文件定义了一套完整的类型和接口规范,确保了良好的类型安全性和代码提示。

语音合成选项

export type SpeakOptions = {  
    /**  
     * 语速  
     * 可选,支持范围 [0.5-2],默认值为 1  
     */  
    speed ?: number;  
    /**  
     * 音量  
     * 可选,支持范围 [0-2],默认值为 1  
     */  
    volume ?: number;  
    /**  
     * 音调  
     * 可选,支持范围 [0.5-2],默认值为 1  
     */  
    pitch ?: number;  
    /**  
     * 语境,播放阿拉伯数字用的语种  
     * 可选,支持 "zh-CN" 中文与 "en-US" 英文,默认 "zh-CN"  
     */  
    language ?: Language;  
    // 其他配置项...  
}

音色信息

export type VoiceInfo = {  
    language : Language;  
    person : number;  
    name : string;  
    gender : 'male' | 'female';  
    description : string;  
    status : 'available' | 'downloadable' | 'unavailable';  
}

语音状态

export type SpeechStatus = 'idle' | 'speaking' | 'paused' | 'uninitialized';

3.2 主要功能接口

export interface LimeTTS {  
    /**  
     * 朗读指定文本  
     * @param text 要朗读的文本  
     * @param options 可选参数,如语音ID、语速等  
     */  
    speak(text : string) : void;  
    speak(text : string, options : SpeakOptions | null) : void;  

    /**  
     * 停止当前朗读  
     */  
    stop() : void;  

    /**  
     * 暂停当前朗读  
     */  
    pause() : void;  

    /**  
     * 获取可用语音列表  
     */  
    getVoices() : Promise<VoiceInfo[]>;  

    /**  
     * 获取当前语音状态  
     */  
    getStatus() : SpeechStatus;  

    /**  
     * 销毁TTS实例,释放资源  
     */  
    destroy() : void;  

    /**  
     * 监听TTS事件  
     * @param event 事件类型  
     * @param callback 回调函数  
     */  
    on(event : 'start' | 'end' | 'error' | 'stop', callback : SpeechCallback) : void;  
    off(event : 'start' | 'end' | 'error' | 'stop') : void;  
    off(event : 'start' | 'end' | 'error' | 'stop', callback : SpeechCallback | null) : void;  
}

四、鸿蒙平台实现详解

在本节中,我们将详细学习如何为鸿蒙平台实现TTS功能。我们将通过调用鸿蒙原生的@kit.CoreSpeechKit来实现语音合成,并遵循之前设计的接口规范。

4.0 模块导入

在鸿蒙平台实现中,首先需要导入鸿蒙系统提供的核心语音合成模块:

// 导入鸿蒙语音合成相关模块  
import { textToSpeech } from '@kit.CoreSpeechKit';  
import { BusinessError } from '@kit.BasicServicesKit';  
// 导入公共接口定义  
import { LimeTTS, SpeakOptions, VoiceInfo, SpeechStatus, SpeechCallback } from '../interface.uts';

4.1 核心实现类

uni_modules/lime-tts/utssdk/app-harmony/index.uts文件中,定义了LimeTTSImpl类,该类是鸿蒙平台上TTS功能的核心实现:

class LimeTTSImpl implements LimeTTS {  
    private engine : textToSpeech.TextToSpeechEngine | null = null;  
    private currentVoice : textToSpeech.CreateEngineParams | null = null;  
    private eventListeners : Map<string, Array<SpeechCallback>> = new Map<string, Array<SpeechCallback>>();  
    private isSpeaking : boolean = false;  
    private isPaused : boolean = false;  
    // ...  
}

4.2 引擎初始化

在构造函数中,插件会初始化 TTS 引擎并设置相关回调:

private initialize(options : CreateEngineParams | null) {  
    // 清理旧引擎  
    if (this.engine) {  
        this.engine.shutdown();  
    }  
    const params : textToSpeech.CreateEngineParams = {  
        language: options?.language ?? 'zh-CN',  
        person: options?.person ?? 0,  
        online: 1  
    }  
    this.currentVoice = params  
    const _this = this  
    textToSpeech.createEngine(params).then((res : textToSpeech.TextToSpeechEngine) => {  
        this.engine = res  
        // 设置speak的回调信息  
        let speakListener : textToSpeech.SpeakListener = {  
            // 开始播报回调  
            onStart(requestId : string, response : textToSpeech.StartResponse) {  
                _this.isSpeaking = true;  
                _this.isPaused = false;  
                _this.emit('start', { type: 'start' })  
            },  
            // 其他回调...  
        };  
        this.engine.setListener(speakListener);  
    }).catch((err : BusinessError) => {  
        console.error(`Failed to create engine. Code: ${err.code}, message: ${err.message}.`);  
    });  
}

4.3 文本朗读功能

speak(text : string) : void  
speak(text : string, options : SpeakOptions | null = null) {  
    const extraParams : ESObject = {  
        speed: options?.speed ?? 1,  
        volume: options?.volume ?? 1,  
        pitch: options?.pitch ?? 1,  
        languageContext: options?.language ?? 'zh-CN'  
    } as ESObject  

    this.engine?.speak(text, {  
        requestId: this.generateRequestId(),  
        extraParams  
    } as textToSpeech.SpeakParams);  
}

4.4 音色管理

插件实现了音色列表获取和映射功能,将鸿蒙系统的原生音色信息转换为统一格式:

async getVoices() : Promise<VoiceInfo[]> {  
    try {  
        if (this.engine) {  
            // 使用引擎实例查询  
            const queryParams : textToSpeech.VoiceQuery = {  
                requestId: this.generateRequestId('voice_query'),  
                online: 1  
            };  

            const voices = await this.engine.listVoices(queryParams);  
            return this.mapToVoiceInfo(voices);  
        } else {  
            // 使用全局方法查询  
            const queryParams : textToSpeech.VoiceQuery = {  
                requestId: this.generateRequestId('voice_query'),  
                online: 1  
            };  

            const voices = await textToSpeech.listVoices(queryParams);  
            return this.mapToVoiceInfo(voices);  
        }  
    } catch (error) {  
        console.error('Failed to get voices:', error);  
        return Promise.resolve([])  
    }  
}  

private mapToVoiceInfo(voices : textToSpeech.VoiceInfo[]) : VoiceInfo[] {  
    return voices.map(voice => {  
        // 根据 person 值映射音色名称  
        let name = 'Unknown';  
        let gender : 'male' | 'female' = 'female';  

        switch (voice.person) {  
            case 0:  
            case 13:  
                name = '聆小珊';  
                gender = 'female';  
                break;  
            case 21:  
                name = '凌飞哲';  
                gender = 'male';  
                break;  
            case 8:  
                name = 'Laura';  
                gender = 'female';  
                break;  
        }  

        return {  
            language: voice.language.replace('_', '-'),  
            person: voice.person,  
            name: name,  
            gender: gender,  
            description: voice.description || `${name} ${gender === 'male' ? '男声' : '女声'}`,  
            status: voice.status === 'INSTALLED' ? 'available' :  
                voice.status === 'GA' ? 'downloadable' : 'unavailable'  
        } as VoiceInfo;  
    });  
}

五、测试与验证

在开发完成插件后,我们需要编写测试代码来验证功能是否正常工作。以下是一个用于测试Lime TTS插件的代码示例:

// 导入我们开发的 TTS 工具函数  
import { useTTS } from '@/uni_modules/lime-tts'  

// 创建 TTS 实例 - 测试实例化功能  
const tts = useTTS();  

// 设置事件监听 - 测试事件机制  
// 播放开始事件  
const onStart = (event) => {  
    console.log('语音播放开始');  
};  

// 播放结束事件  
const onEnd = (event) => {  
    console.log('语音播放结束');  
};  

// 错误处理事件  
const onError = (event) => {  
    console.error('语音播放错误:', event);  
};  

// 注册事件监听器  
tts.on('start', onStart);  
tts.on('end', onEnd);  
tts.on('error', onError);  

// 测试核心功能 - 文本朗读  
tts.speak('欢迎使用 Lime TTS 插件!', {  
    speed: 1.0,  // 语速  
    volume: 1.0, // 音量  
    pitch: 1.0   // 音调  
});  

// 测试控制功能  
setTimeout(() => {  
    // 测试暂停功能  
    tts.pause();  
}, 3000);  

// 测试状态查询功能  
const status = tts.getStatus();  
console.log('当前语音状态:', status);  

// 测试音色管理功能  
tts.getVoices().then(voices => {  
    console.log('可用音色列表:', voices);  
});  

// 测试资源释放 - 在组件卸载时调用  
const cleanup = () => {  
    // 移除事件监听器  
    tts.off('start', onStart);  
    tts.off('end', onEnd);  
    tts.off('error', onError);  
    // 销毁实例,释放系统资源  
    tts.destroy();  
};

六、鸿蒙平台特别说明

在鸿蒙平台上,Lime TTS 插件支持以下音色:

  • 中文女声:聆小珊(person: 13,推荐使用)
  • 中文男声:凌飞哲(person: 21,需要下载)
  • 英文女声:Laura(person: 8,需要下载)

七、总结

通过本文的学习,我们详细了解了如何使用UTS技术为uni-app开发鸿蒙平台的TTS插件。这个过程涵盖了从接口定义、模块导入到核心功能实现的完整开发流程,展示了如何通过调用鸿蒙原生的@kit.CoreSpeechKit能力,弥补uni-app在鸿蒙平台上speech API缺失的问题。

开发鸿蒙平台插件的关键要点包括:

  1. 设计统一的跨平台接口,确保API一致性
  2. 正确导入鸿蒙系统模块,如@kit.CoreSpeechKit@kit.BasicServicesKit
  3. 实现核心功能类,处理平台特定的API细节
  4. 封装事件监听机制,提供良好的开发者体验
  5. 处理音色映射和状态管理,确保功能完整性

希望本文能为您学习如何开发uni-app鸿蒙插件提供有价值的参考,帮助您掌握UTS开发技术,为uni-app生态贡献更多优质的平台适配插件。

目前,本插件已上传至uni-app插件市场,全面支持uni-app和uni-appx框架,并兼容安卓、鸿蒙和Web三大平台,iOS平台将在稍晚上线。开发者可以直接在项目中集成使用,体验跨平台的TTS功能。lime-tts

收起阅读 »

【鸿蒙征文】关于鸿蒙折叠屏(宽屏设备)适配的一些分享

鸿蒙折叠屏适配 宽屏适配 鸿蒙征文

随着鸿蒙系统的不断发展以及uni-app x 对鸿蒙的全面支持,相信使用 uni-app X 进行鸿蒙应用及跨平台应用开发,是前端开发的最优选择。近期完成了对公司的已有应用进行鸿蒙平台适配的适配工作,有了 uni-app X 为基础,整个过程变得“轻松、高效”!

在折叠屏的适配过程中获得了一些经验,分享给大家(仅是自己的一些想法,欢迎大家指正~)。

适配目标效果


如上图所示:屏幕展开时(宽屏),卡片为一行两列布局,屏幕折叠时(窄屏)变成一行一个卡片。

原理分享

1 在页面初始化时获取屏幕宽度,识别运行屏幕环境是否为宽屏;
2 在屏幕宽度变化时,重新判处屏幕运行环境;
3 创建对应的 flex 布局样式,使用 :class="" 进行动态样式切换,达到宽屏及折叠屏幕折叠、展开的适配;

源码分享

<template>  
    <view :class="['flex', isTable?'rows':'column', isTable?'flex-wrap':'']">  
        <view   
        v-for="(item, idx) in 10" :key="idx"  
        :class="['item', isTable ? 'w-half':'w-full']" >  
            <text class="text">{{idx}}</text>  
        </view>  
    </view>  
</template>  
<script>  
export default {  
    data() {  
        return {  
            title: 'Hello',  
            windowWidth : 350, // 保留变量,可以记录屏幕宽度,单位 px  
            isTable : false,  
        }  
    },  
    onReady() {  
        //  
        let info = uni.getWindowInfo();  
        this.isTable = info.windowWidth > 400;  
        console.log(this.isTable);  
    },  
    methods: {  

    },  
    onResize : function(e){  
        this.isTable = e.size.windowWidth > 400;  
        console.log(this.isTable);  
    }  
}  
</script>  
<style>  
.flex{display:flex;}  
.rows{flex-direction:row;}  
.column{flex-direction:column;}  
.flex-wrap{flex-wrap:wrap;}  

.item{background-color:#FF0036;}  
.w-full{width:700rpx; height:300rpx; margin:25rpx;}  
.w-half{width:48%; height:200rpx; margin:20rpx 1%;}  
.text{font-size:20px;}  
</style>

总结

1 演示代码通过监听屏幕尺寸变化,使用 isTable 变量记录了屏幕是否为宽屏,然后通过 flex 布局切换,实现了布局目标;
2 演示代码虽然简单,经过自己的布局设计,完全可以适配 宽屏、折叠屏(动态监听折叠事件);
3 如果您在宽屏应用中遇到字体尺寸问题,可以以 text 组件为基础封装一个自己的文本组件,针对不同屏幕尺寸设置不同的字体大小(可以将 isTable 作为属性传递到组件内,通过 watch 观察屏幕变化,动态切换字体尺寸);
4 如果担心 onResize 监听多次触发引起的效率问题,可以使用 setTimeout 函数实现防抖,仅执行短时间内的一次变化即可;

左右功能不同的布局

如果您开发的应用在宽屏模式下,左侧为列表,右侧为详情:
您可以以 isTable 为核心,使用 if 进行右侧功能的条件渲染,如 : 宽屏模式

<view v-if="isTable">右侧功能区</view>

当我们点击列表项目时调用对应函数,同样判断是否宽屏模式,如果是再右侧详情区域展示详情,反之,打开一个新的页面展示详情即可。

一个很基础的原理分享,希望对大家的鸿蒙之旅有所帮助,谢谢阅读。

继续阅读 »

随着鸿蒙系统的不断发展以及uni-app x 对鸿蒙的全面支持,相信使用 uni-app X 进行鸿蒙应用及跨平台应用开发,是前端开发的最优选择。近期完成了对公司的已有应用进行鸿蒙平台适配的适配工作,有了 uni-app X 为基础,整个过程变得“轻松、高效”!

在折叠屏的适配过程中获得了一些经验,分享给大家(仅是自己的一些想法,欢迎大家指正~)。

适配目标效果


如上图所示:屏幕展开时(宽屏),卡片为一行两列布局,屏幕折叠时(窄屏)变成一行一个卡片。

原理分享

1 在页面初始化时获取屏幕宽度,识别运行屏幕环境是否为宽屏;
2 在屏幕宽度变化时,重新判处屏幕运行环境;
3 创建对应的 flex 布局样式,使用 :class="" 进行动态样式切换,达到宽屏及折叠屏幕折叠、展开的适配;

源码分享

<template>  
    <view :class="['flex', isTable?'rows':'column', isTable?'flex-wrap':'']">  
        <view   
        v-for="(item, idx) in 10" :key="idx"  
        :class="['item', isTable ? 'w-half':'w-full']" >  
            <text class="text">{{idx}}</text>  
        </view>  
    </view>  
</template>  
<script>  
export default {  
    data() {  
        return {  
            title: 'Hello',  
            windowWidth : 350, // 保留变量,可以记录屏幕宽度,单位 px  
            isTable : false,  
        }  
    },  
    onReady() {  
        //  
        let info = uni.getWindowInfo();  
        this.isTable = info.windowWidth > 400;  
        console.log(this.isTable);  
    },  
    methods: {  

    },  
    onResize : function(e){  
        this.isTable = e.size.windowWidth > 400;  
        console.log(this.isTable);  
    }  
}  
</script>  
<style>  
.flex{display:flex;}  
.rows{flex-direction:row;}  
.column{flex-direction:column;}  
.flex-wrap{flex-wrap:wrap;}  

.item{background-color:#FF0036;}  
.w-full{width:700rpx; height:300rpx; margin:25rpx;}  
.w-half{width:48%; height:200rpx; margin:20rpx 1%;}  
.text{font-size:20px;}  
</style>

总结

1 演示代码通过监听屏幕尺寸变化,使用 isTable 变量记录了屏幕是否为宽屏,然后通过 flex 布局切换,实现了布局目标;
2 演示代码虽然简单,经过自己的布局设计,完全可以适配 宽屏、折叠屏(动态监听折叠事件);
3 如果您在宽屏应用中遇到字体尺寸问题,可以以 text 组件为基础封装一个自己的文本组件,针对不同屏幕尺寸设置不同的字体大小(可以将 isTable 作为属性传递到组件内,通过 watch 观察屏幕变化,动态切换字体尺寸);
4 如果担心 onResize 监听多次触发引起的效率问题,可以使用 setTimeout 函数实现防抖,仅执行短时间内的一次变化即可;

左右功能不同的布局

如果您开发的应用在宽屏模式下,左侧为列表,右侧为详情:
您可以以 isTable 为核心,使用 if 进行右侧功能的条件渲染,如 : 宽屏模式

<view v-if="isTable">右侧功能区</view>

当我们点击列表项目时调用对应函数,同样判断是否宽屏模式,如果是再右侧详情区域展示详情,反之,打开一个新的页面展示详情即可。

一个很基础的原理分享,希望对大家的鸿蒙之旅有所帮助,谢谢阅读。

收起阅读 »

uni-app 也能开发纯血鸿蒙 App?使用 wot-starter 这样快速上手!

鸿蒙next uni-app鸿蒙开发实践 鸿蒙征文

大家好,我是不如摸鱼去,wot-ui 的“主理人”,欢迎来到我的 uni-app 分享专栏。

wot-ui 是当下最流行的 uni-app vue3 组件库之一。

2024年,华为原生鸿蒙 HarmonyOS NEXT(5.0)正式发布,随后 uni-app 宣布了对原生鸿蒙的支持。如今 HarmonyOS 6 都发布了,uni-app 对纯血鸿蒙的支持如何了?我们今天来探索使用 wot-starter 构建支持 HarmonyOS NEXT 的 App。

项目环境

我选择使用我们团队维护的 wot-starter 作为起手项目,VSCode 作为主要开发工具,HbuilderX 和 DevEco Studio 作为调试和打包工具。

技术栈:

  • wot-starter: 基于 vitesse-uni-app 深度整合 Wot UI 组件库的快速启动模板 https://starter.wot-ui.cn/

开发工具:

  • HbuilderX: uni-app 专属开发工具 https://www.dcloud.io/hbuilderx.html
  • DevEco Studio: 开发 HarmonyOS 应用及元服务的集成开发环境(IDE)https://developer.huawei.com/consumer/cn/deveco-studio/
  • VSCode: 大家的最爱 https://code.visualstudio.com/

环境信息:

  • node.js v22.17.1

所有的开发工具我们可以安装最新的版本。

运行

如果手上有 HarmonyOS NEXT 系统的手机可以将手机的开发者模式打开连接到电脑上,如果没有则可以安装鸿蒙模拟器,参考文档 https://uniapp.dcloud.net.cn/tutorial/harmony/runbuild.html#connectvirtually 进行安装启动

配置 DevEco-Studio 路径

配置调试证书

运行到手机

选择运行设备 or 模拟器后点击运行,在编译代码构建运行包的时候,有三个缓存使用策略可供选择:

  • 根据变化差量更新缓存:正常使用缓存来避免重复操作,提高构建效率。
  • 强制使用缓存,跳过编译:如果没有修改代码,只想重新运行起来,则可以使用这种方式,此时若 HBuilderX 检查到已经有构建好的运行包存在,则直接安装运行,否则按正常方式构建再运行。
  • 清空缓存:每次升级 HBuilderX 之后,新旧版本的鸿蒙工程目录可能不完全兼容,为避免旧版本的干扰,首次运行的时候可以选择这个选项。 另外,如果运行时出现结果不符合预期的奇怪情况,可以尝试使用这种方式重新构建运行,以消除缓存错乱带来的干扰。

打包

创建App

我们访问https://developer.huawei.com/consumer/cn/service/josp/agc/index.html#/harmonyOSDevPlatform/172249065903274453,注册账号并新建一个 APP ID,这一步我们在申请调试证书时应该已经做过。

申请发布证书

参考教程 https://developer.huawei.com/consumer/cn/doc/app/agc-help-add-releasecert-0000001946273961 申请发布证书并保存下来。

生成证书请求文件

申请发布证书时需要证书请求文件,我们参考教程 https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/ide-signing#section462703710326 在 DevEco Studio 中生成证书请求文件并保存。

注意填写的内容和生成的证书文件都需要妥善保管

配置打包证书

我们在 hbuilderx 中配置打包证书,参考教程:https://uniapp.dcloud.net.cn/tutorial/harmony/runbuild.html#signing-configs

打包

按照下图操作即可打包出 app 文件。

运行效果

运行效果如下图,与 iOS、安卓应用基本一致。

注意点

在运行到原生鸿蒙的过程中,有一些点需要注意下:

  • harmony os 条件编译不可以使用APP-PLUS,可以使用APP
  • plus API 大部分不可用
  • manifest.json 中配置的证书信息,主要是 app-harmony 对应的内容要手动同步到manifest.config.ts下,不然下次运行就没了。
  • 自行探索...

CI/CD

目前是依赖 HbuilderX 和 DevEco Studio 做 APP 构建的,HbuilderX CLI 也可以打包鸿蒙应用,我期望可以脱离 HbuilderX,使用 app-harmony 产物加 DevEco Studio 做 CI/CD,类似原来安卓离线打包一样的流程,目前还在探索中。

总结

我们今天验证了 通过 wot-starter 模板开发纯血鸿蒙(HarmonyOS NEXT)应用。技术栈基于 wot-ui 组件库与 vitesse-uni-app,结合VSCode、HBuilderX及DevEco Studio实现开发调试。流程涵盖环境配置、证书申请、真机/模拟器运行及打包发布,运行效果与安卓/iOS一致。需注意:条件编译禁用APP-PLUS(改用APP),plus API大部分不可用。当前构建依赖HBuilderX,未来探索通过app-harmony产物结合DevEco Studio实现CI/CD,为跨平台开发提供新路径。

参考资源

  • wot-ui: https://wot-ui.cn/
  • DevEco Studio: https://developer.huawei.com/consumer/cn/deveco-studio/
  • wot-starter: https://starter.wot-ui.cn/
  • uni-app 鸿蒙教程: https://uniapp.dcloud.net.cn/tutorial/harmony/runbuild.html

欢迎评论区沟通、讨论👇👇

继续阅读 »

大家好,我是不如摸鱼去,wot-ui 的“主理人”,欢迎来到我的 uni-app 分享专栏。

wot-ui 是当下最流行的 uni-app vue3 组件库之一。

2024年,华为原生鸿蒙 HarmonyOS NEXT(5.0)正式发布,随后 uni-app 宣布了对原生鸿蒙的支持。如今 HarmonyOS 6 都发布了,uni-app 对纯血鸿蒙的支持如何了?我们今天来探索使用 wot-starter 构建支持 HarmonyOS NEXT 的 App。

项目环境

我选择使用我们团队维护的 wot-starter 作为起手项目,VSCode 作为主要开发工具,HbuilderX 和 DevEco Studio 作为调试和打包工具。

技术栈:

  • wot-starter: 基于 vitesse-uni-app 深度整合 Wot UI 组件库的快速启动模板 https://starter.wot-ui.cn/

开发工具:

  • HbuilderX: uni-app 专属开发工具 https://www.dcloud.io/hbuilderx.html
  • DevEco Studio: 开发 HarmonyOS 应用及元服务的集成开发环境(IDE)https://developer.huawei.com/consumer/cn/deveco-studio/
  • VSCode: 大家的最爱 https://code.visualstudio.com/

环境信息:

  • node.js v22.17.1

所有的开发工具我们可以安装最新的版本。

运行

如果手上有 HarmonyOS NEXT 系统的手机可以将手机的开发者模式打开连接到电脑上,如果没有则可以安装鸿蒙模拟器,参考文档 https://uniapp.dcloud.net.cn/tutorial/harmony/runbuild.html#connectvirtually 进行安装启动

配置 DevEco-Studio 路径

配置调试证书

运行到手机

选择运行设备 or 模拟器后点击运行,在编译代码构建运行包的时候,有三个缓存使用策略可供选择:

  • 根据变化差量更新缓存:正常使用缓存来避免重复操作,提高构建效率。
  • 强制使用缓存,跳过编译:如果没有修改代码,只想重新运行起来,则可以使用这种方式,此时若 HBuilderX 检查到已经有构建好的运行包存在,则直接安装运行,否则按正常方式构建再运行。
  • 清空缓存:每次升级 HBuilderX 之后,新旧版本的鸿蒙工程目录可能不完全兼容,为避免旧版本的干扰,首次运行的时候可以选择这个选项。 另外,如果运行时出现结果不符合预期的奇怪情况,可以尝试使用这种方式重新构建运行,以消除缓存错乱带来的干扰。

打包

创建App

我们访问https://developer.huawei.com/consumer/cn/service/josp/agc/index.html#/harmonyOSDevPlatform/172249065903274453,注册账号并新建一个 APP ID,这一步我们在申请调试证书时应该已经做过。

申请发布证书

参考教程 https://developer.huawei.com/consumer/cn/doc/app/agc-help-add-releasecert-0000001946273961 申请发布证书并保存下来。

生成证书请求文件

申请发布证书时需要证书请求文件,我们参考教程 https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/ide-signing#section462703710326 在 DevEco Studio 中生成证书请求文件并保存。

注意填写的内容和生成的证书文件都需要妥善保管

配置打包证书

我们在 hbuilderx 中配置打包证书,参考教程:https://uniapp.dcloud.net.cn/tutorial/harmony/runbuild.html#signing-configs

打包

按照下图操作即可打包出 app 文件。

运行效果

运行效果如下图,与 iOS、安卓应用基本一致。

注意点

在运行到原生鸿蒙的过程中,有一些点需要注意下:

  • harmony os 条件编译不可以使用APP-PLUS,可以使用APP
  • plus API 大部分不可用
  • manifest.json 中配置的证书信息,主要是 app-harmony 对应的内容要手动同步到manifest.config.ts下,不然下次运行就没了。
  • 自行探索...

CI/CD

目前是依赖 HbuilderX 和 DevEco Studio 做 APP 构建的,HbuilderX CLI 也可以打包鸿蒙应用,我期望可以脱离 HbuilderX,使用 app-harmony 产物加 DevEco Studio 做 CI/CD,类似原来安卓离线打包一样的流程,目前还在探索中。

总结

我们今天验证了 通过 wot-starter 模板开发纯血鸿蒙(HarmonyOS NEXT)应用。技术栈基于 wot-ui 组件库与 vitesse-uni-app,结合VSCode、HBuilderX及DevEco Studio实现开发调试。流程涵盖环境配置、证书申请、真机/模拟器运行及打包发布,运行效果与安卓/iOS一致。需注意:条件编译禁用APP-PLUS(改用APP),plus API大部分不可用。当前构建依赖HBuilderX,未来探索通过app-harmony产物结合DevEco Studio实现CI/CD,为跨平台开发提供新路径。

参考资源

  • wot-ui: https://wot-ui.cn/
  • DevEco Studio: https://developer.huawei.com/consumer/cn/deveco-studio/
  • wot-starter: https://starter.wot-ui.cn/
  • uni-app 鸿蒙教程: https://uniapp.dcloud.net.cn/tutorial/harmony/runbuild.html

欢迎评论区沟通、讨论👇👇

收起阅读 »

修复vue2 composition-api 中 computed,app环境无法二次更新的问题

composition_api vue2

vue2项目中使用 @vue/composition-api,h5发现没有任何问题,打包成app就出现了computed无法更新的问题

import { computed, ref } from '@vue/composition-api'  
import { onPageScroll } from '@dcloudio/uni-app'  

export function usePageScroll() {  
  const top = ref(0)  
  onPageScroll(e => {  
    top.value = e.scrollTop  
  })  
  return top  
}  

const navigationBarHeight = uni.getSystemInfoSync().statusBarHeight || 44  
export function useNavbarOpacity(navHeight = navigationBarHeight) {  
  const scrollTop = usePageScroll()  
  return computed(() => Math.min(navHeight, scrollTop.value) / navHeight)  
}

在app里面就发现导航栏的背景颜色无法随着滚动变透明
后面改成 watch+ref就解决了(不知道什么原因)

最终解决方案

vue-composition-plugin.js,在main.ts最开头一行引用,覆盖默认的computed方法


import Vue from 'vue'  
import * as VueCompositionAPI from '@vue/composition-api'  
const watch = VueCompositionAPI.watch  
const ref = VueCompositionAPI.ref  
// app-plus 有bug 直接使用计算属性,计算属性不会自动更新  
// 修复 app 端使用 computed时,第二次修改相关 getter里面的值的时候  
// computed 的值不会更新,去掉原先计算属性的懒加载效果  
// 强制使用watch加ref实现类似 computed  
VueCompositionAPI.computed = function(options){  
let getter = options  
let setter  
if (typeof options === 'object') {  
getter = options.get  
setter = options.set  
}  
let initVal  
const refVal = ref(initVal)  
watch(()=> {  
initVal = getter()  
return initVal  
}, (newVal,oldVal)=>{  
setter && setter(newVal,oldVal)  
refVal.value = newVal  
}, {deep:true,immediate:true})  

return refVal
}
Vue.use(VueCompositionAPI.default)

继续阅读 »

vue2项目中使用 @vue/composition-api,h5发现没有任何问题,打包成app就出现了computed无法更新的问题

import { computed, ref } from '@vue/composition-api'  
import { onPageScroll } from '@dcloudio/uni-app'  

export function usePageScroll() {  
  const top = ref(0)  
  onPageScroll(e => {  
    top.value = e.scrollTop  
  })  
  return top  
}  

const navigationBarHeight = uni.getSystemInfoSync().statusBarHeight || 44  
export function useNavbarOpacity(navHeight = navigationBarHeight) {  
  const scrollTop = usePageScroll()  
  return computed(() => Math.min(navHeight, scrollTop.value) / navHeight)  
}

在app里面就发现导航栏的背景颜色无法随着滚动变透明
后面改成 watch+ref就解决了(不知道什么原因)

最终解决方案

vue-composition-plugin.js,在main.ts最开头一行引用,覆盖默认的computed方法


import Vue from 'vue'  
import * as VueCompositionAPI from '@vue/composition-api'  
const watch = VueCompositionAPI.watch  
const ref = VueCompositionAPI.ref  
// app-plus 有bug 直接使用计算属性,计算属性不会自动更新  
// 修复 app 端使用 computed时,第二次修改相关 getter里面的值的时候  
// computed 的值不会更新,去掉原先计算属性的懒加载效果  
// 强制使用watch加ref实现类似 computed  
VueCompositionAPI.computed = function(options){  
let getter = options  
let setter  
if (typeof options === 'object') {  
getter = options.get  
setter = options.set  
}  
let initVal  
const refVal = ref(initVal)  
watch(()=> {  
initVal = getter()  
return initVal  
}, (newVal,oldVal)=>{  
setter && setter(newVal,oldVal)  
refVal.value = newVal  
}, {deep:true,immediate:true})  

return refVal
}
Vue.use(VueCompositionAPI.default)

收起阅读 »

从零到一开发鸿蒙6原生时钟应用:uni-app x 完全实战指南

鸿蒙征文

🚀 从零到一开发鸿蒙6原生时钟应用:uni-app x 完全实战指南

前言

鸿蒙6时代已来,纯血鸿蒙应用开发正当时!

在移动应用开发的新纪元,鸿蒙6(HarmonyOS 6)作为华为全新一代操作系统,已经完全摆脱了安卓内核,实现了真正的"纯血鸿蒙"。本文将详细介绍如何使用 uni-app x 框架,从零开发一款功能完整、界面精美的鸿蒙6原生时钟应用,集成时钟、闹钟、秒表、计时器四大核心功能。

为什么选择鸿蒙6?

  • 🎯 纯血鸿蒙系统,性能提升30%+
  • 🎯 全新的分布式架构,跨设备协同
  • 🎯 更强大的安全机制
  • 🎯 完善的开发者生态

image-20251028080111473

🌟 项目亮点

  • 🎨 Material Design 深色主题:符合鸿蒙6设计规范,夜间使用友好
  • 📱 鸿蒙6原生应用:使用 uni-app x 编译为纯血鸿蒙原生应用
  • 🚀 完全离线可用:无需网络连接,数据本地存储
  • 🎯 代码结构清晰:模块化设计,易于维护和扩展
  • 极致性能:鸿蒙6平台原生性能,启动速度提升50%
  • 🔥 一次开发,多端部署:支持 HarmonyOS 6、Android、iOS、H5、小程序
  • 🌐 分布式能力:充分利用鸿蒙6的跨设备协同特性
  • 🔐 安全可靠:符合鸿蒙6的安全标准

🛠️ 技术栈

- 核心框架:uni-app x(专为鸿蒙6优化)  
- 开发语言:UTS(TypeScript 超集,编译为鸿蒙原生代码)  
- 前端框架:Vue 3 Composition API  
- 样式方案:SCSS + Material Design  
- 数据存储:uni.storage → 鸿蒙6 Preferences API  
- 目标平台:HarmonyOS 6(纯血鸿蒙系统)  
- API版本:HarmonyOS API 18+

🤔 为什么选择 uni-app x 开发鸿蒙6应用?

uni-app x 是专为鸿蒙6量身打造的跨平台开发框架!

对比维度 原生开发 uni-app x 其他跨平台框架
开发语言 ArkTS UTS(类TypeScript) JavaScript/Dart
性能表现 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐
开发效率 ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐
跨平台能力 ✅ 5端通用 ✅ 有限支持
生态支持 🟢 官方 🟢 官方+社区 🟡 社区
鸿蒙6适配 ✅ 完美 ✅ 完美 🟡 部分

5大核心优势

  1. 🚀 真正的原生性能

    • uni-app x 编译为鸿蒙6原生代码(非WebView)
    • 启动速度快50%,运行流畅度媲美原生
    • 内存占用降低30%
  2. 💡 开发效率提升200%

    • 使用熟悉的 Vue 3 语法
    • 热重载、调试工具完善
    • 一套代码,5端运行(HarmonyOS、Android、iOS、H5、小程序)
  3. 🌐 无缝对接鸿蒙6能力

    • 原生 API 直接调用(通知、振动、后台任务)
    • 支持鸿蒙6分布式特性
    • 完美适配折叠屏、穿戴设备
  4. 🎯 生态丰富

    • 10000+ 插件市场
    • 活跃的开发者社区
    • 完善的文档和示例
  5. 🔥 DCloud 官方支持

    • 全力支持鸿蒙生态建设
    • 及时更新适配鸿蒙最新版本
    • 提供技术支持和培训

一、鸿蒙6应用开发环境搭建

1.1 开发环境要求

要使用 uni-app x 开发鸿蒙6应用,需要准备以下环境:

硬件要求

  • CPU:Intel i5 / AMD Ryzen 5 及以上
  • 内存:16GB 及以上(推荐32GB)
  • 硬盘:至少50GB可用空间(SSD推荐)

软件环境

✅ 操作系统:  
   - Windows 10/11(64位)  
   - macOS 12.0+  

✅ 开发工具:  
   - HBuilderX 4.0+(内置 uni-app x + 鸿蒙6支持)  
   - 下载地址:https://www.dcloud.io/hbuilderx.html  

✅ 鸿蒙开发套件:  
   - HarmonyOS 6 SDK(API Level 18+)  
   - DevEco Studio(可选,用于查看API文档)  
   - 下载地址:https://developer.huawei.com  

✅ 开发语言:  
   - UTS(uni-app TypeScript,编译为鸿蒙原生代码)  

✅ 运行时:  
   - Node.js 18.0+(LTS版本)  
   - npm 9.0+ 或 pnpm 8.0+

获取开发者账号

  1. 注册华为开发者账号:https://developer.huawei.com
  2. 实名认证(个人或企业)
  3. 申请应用签名证书

1.2 鸿蒙6应用配置

manifest.json 中配置鸿蒙6应用信息(关键配置):

{  
  "name": "simpleclock",  
  "appid": "your_app_id",  
  "description": "多功能时钟应用",  
  "versionName": "1.0.0",  
  "versionCode": "100",  
  "uni-app-x": {  
    "harmony": {  
      "minAPIVersion": "12",  
      "targetAPIVersion": "12",  
      "compatibleAPIVersion": "12",  
      "abilities": [  
        {  
          "name": "MainAbility",  
          "description": "时钟应用主界面"  
        }  
      ],  
      "permissions": [  
        "ohos.permission.KEEP_BACKGROUND_RUNNING",  
        "ohos.permission.NOTIFICATION_CONTROLLER",  
        "ohos.permission.VIBRATE"  
      ]  
    }  
  }  
}

1.3 项目架构设计

应用采用经典的 Tab Bar 导航结构,完美适配鸿蒙设计规范:

simpleclock/  
├── pages/  
│   ├── index/         # 时钟页面(世界时钟)  
│   ├── alarm/         # 闹钟页面  
│   ├── stopwatch/     # 秒表页面  
│   ├── timer/         # 计时器页面  
│   └── settings/      # 设置页面  
├── App.uvue           # 应用入口(.uvue 是 uni-app x 专用格式)  
├── pages.json         # 页面配置  
├── uni.scss           # 全局样式变量  
└── manifest.json      # 应用配置(含鸿蒙配置)

1.4 uni-app x 与传统 uni-app 的核心区别

特性 传统 uni-app uni-app x(鸿蒙6) 优势
编译方式 JavaScript 运行时 编译为鸿蒙原生代码 性能提升50%
渲染引擎 WebView 鸿蒙原生渲染 流畅度大幅提升
性能表现 接近原生(80%) 100%原生性能 与原生开发一致
文件格式 .vue .uvue 支持鸿蒙特性
开发语言 JavaScript/TypeScript UTS 类型安全,性能更好
鸿蒙6支持 支持纯血鸿蒙 ✅ 完美支持,未来演进方向 无缝对接鸿蒙API
包体积 较大(含运行时) 更小(30%↓) 启动更快
热更新 ✅ 支持 ⚠️ 受限 鸿蒙安全限制

重要说明

  • 🔥 鸿蒙6应用必须使用 uni-app x
  • 🔥 传统 uni-app 只能运行在 HarmonyOS 兼容模式(非纯血)
  • 🔥 新项目强烈推荐直接使用 uni-app x

1.5 配置 pages.json(鸿蒙适配)

首先配置底部导航栏,这里特别注意鸿蒙平台的样式适配:

{  
  "tabBar": {  
    "color": "#999999",  
    "selectedColor": "#FF6B00",  
    "backgroundColor": "#2C2C2C",  
    "borderStyle": "black",  
    "list": [  
      {  
        "pagePath": "pages/index/index",  
        "text": "时钟"  
      },  
      {  
        "pagePath": "pages/alarm/alarm",  
        "text": "闹钟"  
      },  
      {  
        "pagePath": "pages/stopwatch/stopwatch",  
        "text": "秒表"  
      },  
      {  
        "pagePath": "pages/timer/timer",  
        "text": "计时器"  
      }  
    ]  
  }  
}

1.6 鸿蒙平台条件编译

uni-app x 提供了强大的条件编译能力,可以针对鸿蒙平台做特殊处理:

// #ifdef APP-HARMONY  
// 鸿蒙平台特有代码  
import { vibrator } from '@kit.SensorServiceKit'  
import { notificationManager } from '@kit.NotificationKit'  
// #endif  

// #ifdef APP-ANDROID  
// Android 平台代码  
// #endif  

// #ifndef APP-HARMONY  
// 非鸿蒙平台代码  
// #endif

1.7 Material Design 深色主题配置(鸿蒙适配)

uni.scss 中定义全局颜色变量,完美适配鸿蒙深色模式:

/* Material Design 深色主题颜色 */  
$primary-color: #FF6B00;        // 主色调(橙色)  
$background-dark: #212121;      // 深色背景  
$background-card: #2C2C2C;      // 卡片背景  
$background-elevated: #3A3A3A;  // 高亮背景  

$text-primary: #FFFFFF;         // 主要文本  
$text-secondary: #CCCCCC;       // 次要文本  
$text-disabled: #999999;        // 禁用文本

二、核心功能实现(鸿蒙原生 API 集成)

2.1 时钟页面 - 实时时间与多时区显示

2.1.1 实时时间更新(鸿蒙优化版)

在鸿蒙平台上,我们可以使用更高效的定时器机制。uni-app x 的 setInterval 会被编译为鸿蒙原生定时器:

export default {  
  data() {  
    return {  
      currentTime: '9:23',  
      period: 'a.m.',  
      currentDate: 'Mon, 18 January',  
      timeFormat: 12,  
      timer: null  
    }  
  },  
  onLoad() {  
    this.updateTime()  
    this.startTimer()  
  },  
  methods: {  
    updateTime() {  
      const now = new Date()  
      const hours = now.getHours()  
      const minutes = now.getMinutes()  

      if (this.timeFormat === 12) {  
        const displayHours = hours % 12 || 12  
        this.currentTime = displayHours + ':' +   
          (minutes < 10 ? '0' : '') + minutes  
        this.period = hours >= 12 ? 'p.m.' : 'a.m.'  
      } else {  
        this.currentTime = (hours < 10 ? '0' : '') + hours + ':' +   
          (minutes < 10 ? '0' : '') + minutes  
        this.period = ''  
      }  
    },  
    startTimer() {  
      this.timer = setInterval(() => {  
        this.updateTime()  
      }, 1000)  
    }  
  }  
}

2.1.2 时间格式切换与鸿蒙数据持久化

使用 uni.storage API,在鸿蒙平台上会自动映射到鸿蒙的 Preferences 数据存储:

setTimeFormat(format) {  
  this.timeFormat = format  
  this.updateTime()  
  // 在鸿蒙平台会自动使用 Preferences API  
  uni.setStorageSync('timeFormat', format)  
}  

// 鸿蒙平台的存储路径  
// /data/storage/el2/base/preferences/时钟应用/

2.1.3 鸿蒙系统时间获取优化

// #ifdef APP-HARMONY  
import { systemDateTime } from '@kit.BasicServicesKit'  

// 获取鸿蒙系统时间(更精确)  
const harmonyTime = systemDateTime.getCurrentTime()  
// #endif

2.2 闹钟页面 - 鸿蒙通知与后台任务

2.2.1 鸿蒙权限申请

在开发闹钟功能前,需要申请鸿蒙相关权限:

// manifest.json - 鸿蒙权限配置  
{  
  "uni-app-x": {  
    "harmony": {  
      "permissions": [  
        "ohos.permission.NOTIFICATION_CONTROLLER",  // 通知权限  
        "ohos.permission.VIBRATE",                  // 振动权限  
        "ohos.permission.KEEP_BACKGROUND_RUNNING"   // 后台运行  
      ],  
      "backgroundModes": ["dataTransfer", "audioPlayback"]  
    }  
  }  
}
// 运行时请求权限(鸿蒙平台)  
// #ifdef APP-HARMONY  
import { abilityAccessCtrl } from '@kit.AbilityKit'  

async function requestPermissions() {  
  const atManager = abilityAccessCtrl.createAtManager()  
  try {  
    await atManager.requestPermissionsFromUser(getContext(), [  
      'ohos.permission.NOTIFICATION_CONTROLLER',  
      'ohos.permission.VIBRATE'  
    ])  
  } catch (err) {  
    console.error('权限申请失败', err)  
  }  
}  
// #endif

2.2.2 数据结构设计

闹钟数据结构设计如下:

{  
  time: '08:00',              // 时间  
  label: '起床',              // 标签  
  repeat: [false, true, ...], // 重复(周日到周六)  
  ringtone: 0,                // 铃声索引  
  vibrate: true,              // 振动  
  gradualVolume: true,        // 音量渐增  
  snooze: 10,                 // 贪睡时长(分钟)  
  enabled: true               // 是否启用  
}

2.2.3 鸿蒙本地存储实现

uni.storage 在鸿蒙平台会自动使用 Preferences API,提供高性能的键值对存储:

methods: {  
  // 加载闹钟  
  loadAlarms() {  
    const saved = uni.getStorageSync('alarms')  
    if (saved) {  
      this.alarms = JSON.parse(saved)  
    }  
  },  

  // 保存闹钟  
  saveAlarms() {  
    uni.setStorageSync('alarms', JSON.stringify(this.alarms))  
  },  

  // 添加/编辑闹钟  
  saveAlarm() {  
    if (this.editIndex >= 0) {  
      // 编辑现有闹钟  
      this.alarms[this.editIndex] = this.editAlarmData  
    } else {  
      // 添加新闹钟  
      this.alarms.push(this.editAlarmData)  
    }  
    this.saveAlarms()  
    this.closeDialog()  
  },  

  // 删除闹钟  
  deleteAlarm() {  
    uni.showModal({  
      title: '确认删除',  
      content: '确定要删除这个闹钟吗?',  
      success: (res) => {  
        if (res.confirm) {  
          this.alarms.splice(this.editIndex, 1)  
          this.saveAlarms()  
        }  
      }  
    })  
  }  
}

2.2.4 鸿蒙通知发送

使用鸿蒙原生通知 API 发送闹钟提醒:

// #ifdef APP-HARMONY  
import notificationManager from '@ohos.notificationManager'  

// 发送鸿蒙通知  
async function sendAlarmNotification(alarmData) {  
  const notificationRequest = {  
    id: alarmData.id,  
    content: {  
      contentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,  
      normal: {  
        title: '闹钟提醒',  
        text: alarmData.label || '该起床了!',  
        additionalText: new Date().toLocaleTimeString()  
      }  
    },  
    actionButtons: [  
      {  
        title: '贪睡',  
        wantAgent: createSnoozeWantAgent()  
      },  
      {  
        title: '关闭',  
        wantAgent: createCloseWantAgent()  
      }  
    ]  
  }  

  try {  
    await notificationManager.publish(notificationRequest)  
  } catch (err) {  
    console.error('通知发送失败', err)  
  }  
}  
// #endif  

// 跨平台兼容写法  
function sendNotification(alarmData) {  
  // #ifdef APP-HARMONY  
  sendAlarmNotification(alarmData)  
  // #endif  

  // #ifndef APP-HARMONY  
  uni.showModal({  
    title: '闹钟提醒',  
    content: alarmData.label || '该起床了!'  
  })  
  // #endif  
}

2.2.5 鸿蒙振动反馈

// #ifdef APP-HARMONY  
import vibrator from '@ohos.vibrator'  

// 鸿蒙振动效果  
function vibrateHarmony() {  
  try {  
    // 预定义振动效果  
    vibrator.startVibration({  
      type: 'preset',  
      effectId: 'haptic.clock.timer',  
      count: 3  
    })  

    // 或自定义振动  
    vibrator.startVibration({  
      type: 'time',  
      duration: 1000  
    })  
  } catch (err) {  
    console.error('振动失败', err)  
  }  
}  
// #endif  

// 跨平台振动封装  
function triggerVibration() {  
  // #ifdef APP-HARMONY  
  vibrateHarmony()  
  // #endif  

  // #ifndef APP-HARMONY  
  uni.vibrateShort()  
  // #endif  
}

2.2.6 弹窗表单设计

使用自定义弹窗组件实现闹钟编辑界面:

<view v-if="showDialog" class="dialog-mask" @click="closeDialog">  
  <view class="dialog" @click.stop>  
    <view class="dialog-header">  
      <text class="dialog-title">  
        {{editIndex >= 0 ? '编辑闹钟' : '添加闹钟'}}  
      </text>  
    </view>  

    <view class="dialog-content">  
      <!-- 时间选择器 -->  
      <picker mode="time" :value="editAlarmData.time"   
              @change="onTimeChange">  
        <text>{{editAlarmData.time}}</text>  
      </picker>  

      <!-- 周重复选择 -->  
      <view class="week-selector">  
        <text v-for="(day, i) in weekDays" :key="i"   
              :class="{'active': editAlarmData.repeat[i]}"  
              @click="toggleDay(i)">  
          {{day}}  
        </text>  
      </view>  
    </view>  
  </view>  
</view>

2.3 秒表页面 - 高精度计时与圈数记录

2.3.1 高精度计时实现

使用 10ms 间隔实现百分之一秒精度:

export default {  
  data() {  
    return {  
      isRunning: false,  
      totalTime: 0,      // 总时间(毫秒)  
      startTime: 0,  
      timer: null,  
      laps: []           // 圈数记录  
    }  
  },  
  computed: {  
    formattedTime() {  
      const totalSeconds = Math.floor(this.totalTime / 1000)  
      const minutes = Math.floor(totalSeconds / 60)  
      const seconds = totalSeconds % 60  
      return (minutes < 10 ? '0' : '') + minutes + ':' +   
             (seconds < 10 ? '0' : '') + seconds  
    },  
    milliseconds() {  
      return String(Math.floor((this.totalTime % 1000) / 10))  
        .padStart(2, '0')  
    }  
  },  
  methods: {  
    start() {  
      this.isRunning = true  
      this.startTime = Date.now() - this.totalTime  
      this.timer = setInterval(() => {  
        this.totalTime = Date.now() - this.startTime  
      }, 10)  // 10ms 更新一次  
    }  
  }  
}

2.3.2 圈数记录与排序

实现圈数记录和多种排序方式:

methods: {  
  recordLap() {  
    const lapTime = this.totalTime - this.lastLapTime  
    this.lapCounter++  

    this.laps.unshift({  
      id: Date.now(),  
      index: this.lapCounter,  
      time: this.formatTime(lapTime),  
      rawTime: lapTime  
    })  

    this.lastLapTime = this.totalTime  
  }  
},  
computed: {  
  sortedLaps() {  
    let sorted = [...this.laps]  

    // 根据排序模式排序  
    if (this.sortMode === 'fastest') {  
      sorted.sort((a, b) => a.rawTime - b.rawTime)  
    } else if (this.sortMode === 'slowest') {  
      sorted.sort((a, b) => b.rawTime - a.rawTime)  
    }  

    // 标记最快和最慢  
    if (sorted.length > 1) {  
      const times = this.laps.map(l => l.rawTime)  
      const fastest = Math.min(...times)  
      const slowest = Math.max(...times)  

      sorted = sorted.map(lap => ({  
        ...lap,  
        isFastest: lap.rawTime === fastest,  
        isSlowest: lap.rawTime === slowest && fastest !== slowest  
      }))  
    }  

    return sorted  
  }  
}

2.4 计时器页面 - 倒计时与进度显示

2.4.1 时间选择器实现

使用 picker-view 实现时间选择:

<view class="picker-row">  
  <view class="picker-group">  
    <picker-view class="picker" :value="[hours]"   
                 @change="onHoursChange">  
      <picker-view-column>  
        <view v-for="n in 24" :key="n" class="picker-item">  
          <text>{{n - 1}}</text>  
        </view>  
      </picker-view-column>  
    </picker-view>  
    <text class="picker-label">小时</text>  
  </view>  

  <!-- 分钟和秒的选择器类似 -->  
</view>

2.4.2 进度条实现

实时计算并显示倒计时进度:

computed: {  
  progressPercent() {  
    if (this.totalSeconds === 0) return 0  
    return Math.floor(  
      (this.totalSeconds - this.remainingTime) /   
      this.totalSeconds * 100  
    )  
  }  
}
<view class="progress-bar">  
  <view class="progress-fill"   
        :style="{width: progressPercent + '%'}">  
  </view>  
</view>

2.4.3 倒计时完成处理

onTimerComplete() {  
  this.stopTimer()  
  this.isRunning = false  

  // 振动提醒  
  if (this.vibrate) {  
    // uni.vibrateLong()  
  }  

  // 弹窗通知  
  if (this.notification) {  
    uni.showModal({  
      title: '计时完成',  
      content: '计时器时间已到!',  
      showCancel: false  
    })  
  }  
}

2.5 设置页面 - 丰富的个性化选项

2.5.1 屏幕常亮功能

onKeepScreenOnChange(e) {  
  this.keepScreenOn = e.detail.value  
  uni.setStorageSync('keepScreenOn', this.keepScreenOn)  

  uni.setKeepScreenOn({  
    keepScreenOn: this.keepScreenOn  
  })  

  uni.showToast({  
    title: this.keepScreenOn ?   
      '屏幕常亮已开启' : '屏幕常亮已关闭',  
    icon: 'none'  
  })  
}

2.5.2 数据清除功能

clearData() {  
  uni.showModal({  
    title: '确认清除',  
    content: '此操作将清除所有闹钟和设置数据,无法恢复。确定要继续吗?',  
    confirmText: '清除',  
    confirmColor: '#F44336',  
    success: (res) => {  
      if (res.confirm) {  
        uni.clearStorageSync()  
        uni.showToast({  
          title: '数据已清除',  
          icon: 'success'  
        })  
        // 重启应用  
        setTimeout(() => {  
          uni.reLaunch({  
            url: '/pages/index/index'  
          })  
        }, 1500)  
      }  
    }  
  })  
}

三、UI/UX 设计与优化

3.1 Material Design 样式实现

3.1.1 卡片阴影效果

.card {  
  background-color: #2C2C2C;  
  border-radius: 8px;  
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);  
}

3.1.2 悬浮按钮(FAB)

.add-btn {  
  position: fixed;  
  right: 20px;  
  bottom: 100px;  
  width: 60px;  
  height: 60px;  
  background-color: #FF6B00;  
  border-radius: 30px;  
  display: flex;  
  justify-content: center;  
  align-items: center;  
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);  
}

3.2 颜色系统

深色主题配色方案:

元素 颜色值 用途
主色 #FF6B00 按钮、选中状态、导航栏
背景 #212121 页面背景
卡片 #2C2C2C 列表项、卡片背景
分隔 #3A3A3A 边框、分隔线
文本主 #FFFFFF 主要文本
文本次 #CCCCCC 次要文本
文本禁 #999999 禁用文本

3.3 交互优化

3.3.1 空状态设计

<view v-if="alarms.length === 0" class="empty-state">  
  <text class="empty-text">暂无闹钟</text>  
  <text class="empty-hint">点击下方 "+" 按钮添加闹钟</text>  
</view>

3.3.2 加载状态与反馈

// 操作成功提示  
uni.showToast({  
  title: '保存成功',  
  icon: 'success'  
})  

// 确认对话框  
uni.showModal({  
  title: '确认删除',  
  content: '确定要删除这个闹钟吗?',  
  success: (res) => {  
    if (res.confirm) {  
      // 执行删除  
    }  
  }  
})

四、性能优化

4.1 计时器管理

确保在页面卸载时清除定时器,避免内存泄漏:

export default {  
  onUnload() {  
    this.stopTimer()  
  },  
  methods: {  
    stopTimer() {  
      if (this.timer) {  
        clearInterval(this.timer)  
        this.timer = null  
      }  
    }  
  }  
}

4.2 数据缓存策略

  • 使用 uni.setStorageSync 同步存储关键数据
  • 页面加载时从本地读取数据,减少计算
  • 数据变更时立即保存,确保数据一致性

4.3 列表渲染优化

使用 :key 提升列表渲染性能:

<view v-for="(alarm, index) in alarms"   
      :key="alarm.id || index"   
      class="alarm-item">  
  <!-- 内容 -->  
</view>

五、鸿蒙平台深度适配

5.1 鸿蒙返回键处理

鸿蒙系统的返回键需要特殊处理,实现双击退出:

// #ifdef APP-HARMONY  
let firstBackTime = 0  

export default {  
  onLastPageBackPress: function () {  
    console.log('HarmonyOS 返回键按下')  
    if (firstBackTime == 0) {  
      uni.showToast({  
        title: '再按一次退出应用',  
        position: 'bottom',  
      })  
      firstBackTime = Date.now()  
      setTimeout(() => {  
        firstBackTime = 0  
      }, 2000)  
    } else if (Date.now() - firstBackTime < 2000) {  
      uni.exit() // 退出应用  
    }  
  }  
}  
// #endif

5.2 鸿蒙屏幕常亮

利用鸿蒙的电源管理 API 实现屏幕常亮:

// #ifdef APP-HARMONY  
import { runningLock } from '@kit.BasicServicesKit'  

let screenLock = null  

// 保持屏幕常亮  
function keepScreenOn() {  
  try {  
    screenLock = runningLock.createRunningLock('screen',   
      runningLock.RunningLockType.RUNNINGLOCK_SCREEN)  
    screenLock.lock(0) // 0 表示永久锁定  
  } catch (err) {  
    console.error('屏幕常亮失败', err)  
  }  
}  

// 释放屏幕锁  
function releaseScreenLock() {  
  if (screenLock) {  
    screenLock.unlock()  
    screenLock = null  
  }  
}  
// #endif  

// 跨平台封装  
function setKeepScreenOn(keep) {  
  // #ifdef APP-HARMONY  
  if (keep) {  
    keepScreenOn()  
  } else {  
    releaseScreenLock()  
  }  
  // #endif  

  // #ifndef APP-HARMONY  
  uni.setKeepScreenOn({ keepScreenOn: keep })  
  // #endif  
}

5.3 鸿蒙后台任务

实现闹钟的后台运行:

// #ifdef APP-HARMONY  
import backgroundTaskManager from '@ohos.resourceschedule.backgroundTaskManager'  

// 申请长时任务  
async function requestBackgroundTask() {  
  try {  
    await backgroundTaskManager.requestSuspendDelay('时钟后台任务', () => {  
      console.log('后台任务即将被挂起')  
      // 保存状态  
    })  
  } catch (err) {  
    console.error('后台任务申请失败', err)  
  }  
}  
// #endif

5.4 鸿蒙安全区域适配

考虑鸿蒙设备的刘海屏、水滴屏和折叠屏:

.container {  
  // 鸿蒙安全区域  
  padding-top: env(safe-area-inset-top);  
  padding-bottom: env(safe-area-inset-bottom);  
  padding-left: env(safe-area-inset-left);  
  padding-right: env(safe-area-inset-right);  
}  

// 鸿蒙折叠屏适配  
@media screen and (min-width: 600px) {  
  .container {  
    max-width: 600px;  
    margin: 0 auto;  
  }  
}

5.5 鸿蒙生命周期适配

export default {  
  // 应用前台  
  onShow() {  
    // #ifdef APP-HARMONY  
    console.log('HarmonyOS 应用进入前台')  
    this.resumeTimers() // 恢复计时器  
    // #endif  
  },  

  // 应用后台  
  onHide() {  
    // #ifdef APP-HARMONY  
    console.log('HarmonyOS 应用进入后台')  
    this.pauseTimers() // 暂停计时器  
    this.saveState() // 保存状态  
    // #endif  
  }  
}

5.6 鸿蒙深色模式适配

鸿蒙系统的深色模式可以自动切换:

// #ifdef APP-HARMONY  
import { configuration } from '@kit.AbilityKit'  

// 监听系统主题变化  
function watchThemeChange() {  
  const config = configuration.getConfiguration()  
  const isDark = config.colorMode === configuration.ColorMode.COLOR_MODE_DARK  

  // 应用深色主题  
  if (isDark) {  
    this.applyDarkTheme()  
  } else {  
    this.applyLightTheme()  
  }  
}  
// #endif

六、鸿蒙应用测试与调试

6.1 鸿蒙开发者模式

在鸿蒙设备上启用开发者模式:

  1. 进入"设置" → "关于手机"
  2. 连续点击"版本号"7次
  3. 返回"设置" → "系统和更新" → "开发者选项"
  4. 开启"USB 调试"和"USB 安装"

6.2 HBuilderX 真机调试

# 1. 连接鸿蒙设备  
# 2. 在 HBuilderX 中选择运行设备  
# 3. 点击"运行" → "运行到手机或模拟器" → "运行到 HarmonyOS"  

# 查看日志  
hdc shell hilog | grep "simpleclock"

6.3 鸿蒙特有功能测试清单

  • [ ] 通知权限:闹钟通知是否正常显示
  • [ ] 振动权限:振动反馈是否生效
  • [ ] 后台任务:应用后台时闹钟是否正常工作
  • [ ] 屏幕常亮:前台运行时屏幕是否保持常亮
  • [ ] 数据持久化:应用重启后数据是否保留
  • [ ] 深色模式:系统切换主题时应用是否自动适配
  • [ ] 折叠屏适配:在折叠屏设备上布局是否正常
  • [ ] 返回键处理:双击返回是否正常退出

6.4 性能测试(鸿蒙平台)

// 使用鸿蒙性能追踪  
// #ifdef APP-HARMONY  
import hiTraceMeter from '@ohos.hiTraceMeter'  

// 开始性能追踪  
hiTraceMeter.startTrace('stopwatch_timing', 1001)  

// 执行计时逻辑  
this.updateStopwatchTime()  

// 结束追踪  
hiTraceMeter.finishTrace('stopwatch_timing', 1001)  
// #endif

6.2 边界情况处理

// 防止重复点击  
if (this.totalSeconds === 0) {  
  uni.showToast({  
    title: '请设置计时时间',  
    icon: 'none'  
  })  
  return  
}  

// 数据验证  
if (!editAlarmData.time) {  
  uni.showToast({  
    title: '请选择时间',  
    icon: 'none'  
  })  
  return  
}

七、项目总结

7.1 技术亮点

  1. 完整的功能闭环:从时间显示到提醒管理,覆盖用户全部需求
  2. 优雅的代码结构:模块化设计,职责清晰
  3. 良好的用户体验:Material Design + 深色主题
  4. 高性能实现:合理使用定时器,优化渲染性能
  5. 跨平台兼容:一套代码,多端运行

7.2 可扩展方向

  1. 云同步功能:支持多设备数据同步
  2. 更多铃声:支持自定义铃声上传
  3. 统计分析:记录使用习惯,提供数据分析
  4. 智能提醒:根据用户习惯智能推荐闹钟时间
  5. 主题定制:支持更多颜色主题

7.3 开发心得

  1. 用户体验至上:每一个交互细节都要仔细打磨
  2. 代码质量:保持代码简洁,注重可维护性
  3. 性能优化:及时清理资源,避免内存泄漏
  4. 测试驱动:充分测试各种边界情况
  5. 持续迭代:根据用户反馈不断优化改进

八、源码与资源

8.1 项目结构

simpleclock/  
├── pages/  
│   ├── index/index.uvue      # 264 行  
│   ├── alarm/alarm.uvue      # 430 行  
│   ├── stopwatch/stopwatch.uvue  # 280 行  
│   ├── timer/timer.uvue      # 300 行  
│   └── settings/settings.uvue    # 350 行  
├── App.uvue                  # 124 行  
├── pages.json                # 76 行  
├── uni.scss                  # 77 行  
├── README.md                 # 项目说明  
└── 使用指南.md               # 用户手册

8.2 代码统计

  • 总代码量:约 1900+ 行
  • 页面数:5 个主要页面
  • 组件数:多个自定义组件
  • 功能模块:4 个核心功能

8.3 运行环境

  • 开发工具:HBuilderX 4.0+
  • uni-app x 版本:最新版
  • 鸿蒙系统:HarmonyOS 6
  • 鸿蒙 SDK:API Level 10+
  • 测试设备:
    • 华为 Mate 60 系列(HarmonyOS 6)
    • 华为 MatePad Pro(折叠屏测试)
    • 鸿蒙模拟器

8.4 鸿蒙应用发布

8.4.1 应用签名

# 在华为开发者联盟申请应用签名  
# 下载签名文件并配置到 manifest.json  

{  
  "uni-app-x": {  
    "harmony": {  
      "signing": {  
        "profile": "path/to/profile.p7b",  
        "certFile": "path/to/cert.pem",  
        "keyFile": "path/to/key.p12",  
        "keyPassword": "your_password"  
      }  
    }  
  }  
}

8.4.2 打包发布

# HBuilderX 中操作:  
# 1. 发行 → 原生App-云打包  
# 2. 选择 HarmonyOS 平台  
# 3. 填写应用信息和签名配置  
# 4. 点击打包,等待云端编译  
# 5. 下载 .hap 安装包  

# 命令行打包(可选)  
npm run build:app-harmony

8.4.3 应用上架

  1. 登录华为开发者联盟
  2. 进入"应用服务" → "AppGallery Connect"
  3. 创建应用并上传 .hap 包
  4. 填写应用信息(截图、描述、分类)
  5. 提交审核
  6. 审核通过后上架

九、鸿蒙开发常见问题

9.1 权限被拒绝

问题:应用无法发送通知或振动

解决

// 在应用启动时主动请求权限  
// #ifdef APP-HARMONY  
async function checkAndRequestPermissions() {  
  const permissions = [  
    'ohos.permission.NOTIFICATION_CONTROLLER',  
    'ohos.permission.VIBRATE'  
  ]  

  for (let permission of permissions) {  
    const result = await checkPermission(permission)  
    if (!result) {  
      await requestPermission(permission)  
    }  
  }  
}  
// #endif

9.2 后台任务被终止

问题:闹钟在后台不响

解决:申请长时任务授权,确保应用在后台持续运行

9.3 数据丢失

问题:应用卸载后数据丢失

解决:使用鸿蒙云空间同步数据(需要用户登录华为账号)

9.4 性能问题

问题:页面切换卡顿

解决

  • 使用虚拟列表优化长列表渲染
  • 避免在主线程执行耗时操作
  • 使用鸿蒙的 Worker 线程处理复杂计算

结语

本文详细介绍了如何使用 uni-app x 开发一款功能完整的鸿蒙6原生时钟应用,从环境搭建、核心功能实现、鸿蒙6平台深度适配到应用发布,涵盖了鸿蒙6应用开发的各个方面。

🎉 这是一个真实的鸿蒙6原生应用开发案例!

核心要点回顾

  1. uni-app x 的优势

    • ✅ 编译为鸿蒙原生代码,性能卓越
    • ✅ 一套代码多端运行,大幅降低开发成本
    • ✅ 丰富的 API 支持,无缝对接鸿蒙能力
  2. 鸿蒙平台特性

    • ✅ 强大的通知系统
    • ✅ 完善的权限管理
    • ✅ 优秀的后台任务机制
    • ✅ 深度的系统集成
  3. 开发建议

    • 💡 充分利用条件编译,针对鸿蒙做优化
    • 💡 注意权限申请和用户体验
    • 💡 重视性能优化和内存管理
    • 💡 遵循鸿蒙设计规范

希望这篇文章能帮助你:

  • 🎯 零基础入门:从环境搭建到应用发布的完整流程
  • 🎨 深度理解鸿蒙6:掌握鸿蒙6的核心特性和设计理念
  • 💡 实战经验:学习时间管理类应用的最佳实践
  • 🚀 快速上手:30分钟开发出第一个鸿蒙6应用
  • 🔥 抓住机遇:在鸿蒙生态爆发期占据先机

下一步计划

  • [ ] 适配鸿蒙折叠屏设备
  • [ ] 接入华为账号系统
  • [ ] 实现华为云空间数据同步
  • [ ] 添加鸿蒙小部件(Widget)
  • [ ] 接入鸿蒙分布式能力

如果你有任何问题或建议,欢迎交流讨论!让我们一起为鸿蒙生态贡献力量!🚀


关键词:#鸿蒙6 #HarmonyOS6 #uni-app-x #纯血鸿蒙 #原生应用开发 #Vue3 #跨平台开发 #时钟应用

作者:坚果

日期:2025年10月28日
项目地址GitCode - simpleclock
开源协议:MIT License


📢 开源说明

本项目完全开源,欢迎:

  • ⭐ Star 项目,关注后续更新
  • 🍴 Fork 项目,二次开发
  • 🐛 提交 Issue,反馈问题
  • 🤝 提交 PR,贡献代码

当前状态

  • ✅ 核心功能已完成
  • ⚠️ 部分细节待优化(如时区同步、铃声播放等)
  • 🎯 持续迭代中,欢迎贡献代码!

🔗 相关资源

💬 交流讨论

如果你在开发过程中遇到问题,或有好的想法和建议:

让我们一起推动鸿蒙生态发展! 🚀🇨🇳

继续阅读 »

🚀 从零到一开发鸿蒙6原生时钟应用:uni-app x 完全实战指南

前言

鸿蒙6时代已来,纯血鸿蒙应用开发正当时!

在移动应用开发的新纪元,鸿蒙6(HarmonyOS 6)作为华为全新一代操作系统,已经完全摆脱了安卓内核,实现了真正的"纯血鸿蒙"。本文将详细介绍如何使用 uni-app x 框架,从零开发一款功能完整、界面精美的鸿蒙6原生时钟应用,集成时钟、闹钟、秒表、计时器四大核心功能。

为什么选择鸿蒙6?

  • 🎯 纯血鸿蒙系统,性能提升30%+
  • 🎯 全新的分布式架构,跨设备协同
  • 🎯 更强大的安全机制
  • 🎯 完善的开发者生态

image-20251028080111473

🌟 项目亮点

  • 🎨 Material Design 深色主题:符合鸿蒙6设计规范,夜间使用友好
  • 📱 鸿蒙6原生应用:使用 uni-app x 编译为纯血鸿蒙原生应用
  • 🚀 完全离线可用:无需网络连接,数据本地存储
  • 🎯 代码结构清晰:模块化设计,易于维护和扩展
  • 极致性能:鸿蒙6平台原生性能,启动速度提升50%
  • 🔥 一次开发,多端部署:支持 HarmonyOS 6、Android、iOS、H5、小程序
  • 🌐 分布式能力:充分利用鸿蒙6的跨设备协同特性
  • 🔐 安全可靠:符合鸿蒙6的安全标准

🛠️ 技术栈

- 核心框架:uni-app x(专为鸿蒙6优化)  
- 开发语言:UTS(TypeScript 超集,编译为鸿蒙原生代码)  
- 前端框架:Vue 3 Composition API  
- 样式方案:SCSS + Material Design  
- 数据存储:uni.storage → 鸿蒙6 Preferences API  
- 目标平台:HarmonyOS 6(纯血鸿蒙系统)  
- API版本:HarmonyOS API 18+

🤔 为什么选择 uni-app x 开发鸿蒙6应用?

uni-app x 是专为鸿蒙6量身打造的跨平台开发框架!

对比维度 原生开发 uni-app x 其他跨平台框架
开发语言 ArkTS UTS(类TypeScript) JavaScript/Dart
性能表现 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐
开发效率 ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐
跨平台能力 ✅ 5端通用 ✅ 有限支持
生态支持 🟢 官方 🟢 官方+社区 🟡 社区
鸿蒙6适配 ✅ 完美 ✅ 完美 🟡 部分

5大核心优势

  1. 🚀 真正的原生性能

    • uni-app x 编译为鸿蒙6原生代码(非WebView)
    • 启动速度快50%,运行流畅度媲美原生
    • 内存占用降低30%
  2. 💡 开发效率提升200%

    • 使用熟悉的 Vue 3 语法
    • 热重载、调试工具完善
    • 一套代码,5端运行(HarmonyOS、Android、iOS、H5、小程序)
  3. 🌐 无缝对接鸿蒙6能力

    • 原生 API 直接调用(通知、振动、后台任务)
    • 支持鸿蒙6分布式特性
    • 完美适配折叠屏、穿戴设备
  4. 🎯 生态丰富

    • 10000+ 插件市场
    • 活跃的开发者社区
    • 完善的文档和示例
  5. 🔥 DCloud 官方支持

    • 全力支持鸿蒙生态建设
    • 及时更新适配鸿蒙最新版本
    • 提供技术支持和培训

一、鸿蒙6应用开发环境搭建

1.1 开发环境要求

要使用 uni-app x 开发鸿蒙6应用,需要准备以下环境:

硬件要求

  • CPU:Intel i5 / AMD Ryzen 5 及以上
  • 内存:16GB 及以上(推荐32GB)
  • 硬盘:至少50GB可用空间(SSD推荐)

软件环境

✅ 操作系统:  
   - Windows 10/11(64位)  
   - macOS 12.0+  

✅ 开发工具:  
   - HBuilderX 4.0+(内置 uni-app x + 鸿蒙6支持)  
   - 下载地址:https://www.dcloud.io/hbuilderx.html  

✅ 鸿蒙开发套件:  
   - HarmonyOS 6 SDK(API Level 18+)  
   - DevEco Studio(可选,用于查看API文档)  
   - 下载地址:https://developer.huawei.com  

✅ 开发语言:  
   - UTS(uni-app TypeScript,编译为鸿蒙原生代码)  

✅ 运行时:  
   - Node.js 18.0+(LTS版本)  
   - npm 9.0+ 或 pnpm 8.0+

获取开发者账号

  1. 注册华为开发者账号:https://developer.huawei.com
  2. 实名认证(个人或企业)
  3. 申请应用签名证书

1.2 鸿蒙6应用配置

manifest.json 中配置鸿蒙6应用信息(关键配置):

{  
  "name": "simpleclock",  
  "appid": "your_app_id",  
  "description": "多功能时钟应用",  
  "versionName": "1.0.0",  
  "versionCode": "100",  
  "uni-app-x": {  
    "harmony": {  
      "minAPIVersion": "12",  
      "targetAPIVersion": "12",  
      "compatibleAPIVersion": "12",  
      "abilities": [  
        {  
          "name": "MainAbility",  
          "description": "时钟应用主界面"  
        }  
      ],  
      "permissions": [  
        "ohos.permission.KEEP_BACKGROUND_RUNNING",  
        "ohos.permission.NOTIFICATION_CONTROLLER",  
        "ohos.permission.VIBRATE"  
      ]  
    }  
  }  
}

1.3 项目架构设计

应用采用经典的 Tab Bar 导航结构,完美适配鸿蒙设计规范:

simpleclock/  
├── pages/  
│   ├── index/         # 时钟页面(世界时钟)  
│   ├── alarm/         # 闹钟页面  
│   ├── stopwatch/     # 秒表页面  
│   ├── timer/         # 计时器页面  
│   └── settings/      # 设置页面  
├── App.uvue           # 应用入口(.uvue 是 uni-app x 专用格式)  
├── pages.json         # 页面配置  
├── uni.scss           # 全局样式变量  
└── manifest.json      # 应用配置(含鸿蒙配置)

1.4 uni-app x 与传统 uni-app 的核心区别

特性 传统 uni-app uni-app x(鸿蒙6) 优势
编译方式 JavaScript 运行时 编译为鸿蒙原生代码 性能提升50%
渲染引擎 WebView 鸿蒙原生渲染 流畅度大幅提升
性能表现 接近原生(80%) 100%原生性能 与原生开发一致
文件格式 .vue .uvue 支持鸿蒙特性
开发语言 JavaScript/TypeScript UTS 类型安全,性能更好
鸿蒙6支持 支持纯血鸿蒙 ✅ 完美支持,未来演进方向 无缝对接鸿蒙API
包体积 较大(含运行时) 更小(30%↓) 启动更快
热更新 ✅ 支持 ⚠️ 受限 鸿蒙安全限制

重要说明

  • 🔥 鸿蒙6应用必须使用 uni-app x
  • 🔥 传统 uni-app 只能运行在 HarmonyOS 兼容模式(非纯血)
  • 🔥 新项目强烈推荐直接使用 uni-app x

1.5 配置 pages.json(鸿蒙适配)

首先配置底部导航栏,这里特别注意鸿蒙平台的样式适配:

{  
  "tabBar": {  
    "color": "#999999",  
    "selectedColor": "#FF6B00",  
    "backgroundColor": "#2C2C2C",  
    "borderStyle": "black",  
    "list": [  
      {  
        "pagePath": "pages/index/index",  
        "text": "时钟"  
      },  
      {  
        "pagePath": "pages/alarm/alarm",  
        "text": "闹钟"  
      },  
      {  
        "pagePath": "pages/stopwatch/stopwatch",  
        "text": "秒表"  
      },  
      {  
        "pagePath": "pages/timer/timer",  
        "text": "计时器"  
      }  
    ]  
  }  
}

1.6 鸿蒙平台条件编译

uni-app x 提供了强大的条件编译能力,可以针对鸿蒙平台做特殊处理:

// #ifdef APP-HARMONY  
// 鸿蒙平台特有代码  
import { vibrator } from '@kit.SensorServiceKit'  
import { notificationManager } from '@kit.NotificationKit'  
// #endif  

// #ifdef APP-ANDROID  
// Android 平台代码  
// #endif  

// #ifndef APP-HARMONY  
// 非鸿蒙平台代码  
// #endif

1.7 Material Design 深色主题配置(鸿蒙适配)

uni.scss 中定义全局颜色变量,完美适配鸿蒙深色模式:

/* Material Design 深色主题颜色 */  
$primary-color: #FF6B00;        // 主色调(橙色)  
$background-dark: #212121;      // 深色背景  
$background-card: #2C2C2C;      // 卡片背景  
$background-elevated: #3A3A3A;  // 高亮背景  

$text-primary: #FFFFFF;         // 主要文本  
$text-secondary: #CCCCCC;       // 次要文本  
$text-disabled: #999999;        // 禁用文本

二、核心功能实现(鸿蒙原生 API 集成)

2.1 时钟页面 - 实时时间与多时区显示

2.1.1 实时时间更新(鸿蒙优化版)

在鸿蒙平台上,我们可以使用更高效的定时器机制。uni-app x 的 setInterval 会被编译为鸿蒙原生定时器:

export default {  
  data() {  
    return {  
      currentTime: '9:23',  
      period: 'a.m.',  
      currentDate: 'Mon, 18 January',  
      timeFormat: 12,  
      timer: null  
    }  
  },  
  onLoad() {  
    this.updateTime()  
    this.startTimer()  
  },  
  methods: {  
    updateTime() {  
      const now = new Date()  
      const hours = now.getHours()  
      const minutes = now.getMinutes()  

      if (this.timeFormat === 12) {  
        const displayHours = hours % 12 || 12  
        this.currentTime = displayHours + ':' +   
          (minutes < 10 ? '0' : '') + minutes  
        this.period = hours >= 12 ? 'p.m.' : 'a.m.'  
      } else {  
        this.currentTime = (hours < 10 ? '0' : '') + hours + ':' +   
          (minutes < 10 ? '0' : '') + minutes  
        this.period = ''  
      }  
    },  
    startTimer() {  
      this.timer = setInterval(() => {  
        this.updateTime()  
      }, 1000)  
    }  
  }  
}

2.1.2 时间格式切换与鸿蒙数据持久化

使用 uni.storage API,在鸿蒙平台上会自动映射到鸿蒙的 Preferences 数据存储:

setTimeFormat(format) {  
  this.timeFormat = format  
  this.updateTime()  
  // 在鸿蒙平台会自动使用 Preferences API  
  uni.setStorageSync('timeFormat', format)  
}  

// 鸿蒙平台的存储路径  
// /data/storage/el2/base/preferences/时钟应用/

2.1.3 鸿蒙系统时间获取优化

// #ifdef APP-HARMONY  
import { systemDateTime } from '@kit.BasicServicesKit'  

// 获取鸿蒙系统时间(更精确)  
const harmonyTime = systemDateTime.getCurrentTime()  
// #endif

2.2 闹钟页面 - 鸿蒙通知与后台任务

2.2.1 鸿蒙权限申请

在开发闹钟功能前,需要申请鸿蒙相关权限:

// manifest.json - 鸿蒙权限配置  
{  
  "uni-app-x": {  
    "harmony": {  
      "permissions": [  
        "ohos.permission.NOTIFICATION_CONTROLLER",  // 通知权限  
        "ohos.permission.VIBRATE",                  // 振动权限  
        "ohos.permission.KEEP_BACKGROUND_RUNNING"   // 后台运行  
      ],  
      "backgroundModes": ["dataTransfer", "audioPlayback"]  
    }  
  }  
}
// 运行时请求权限(鸿蒙平台)  
// #ifdef APP-HARMONY  
import { abilityAccessCtrl } from '@kit.AbilityKit'  

async function requestPermissions() {  
  const atManager = abilityAccessCtrl.createAtManager()  
  try {  
    await atManager.requestPermissionsFromUser(getContext(), [  
      'ohos.permission.NOTIFICATION_CONTROLLER',  
      'ohos.permission.VIBRATE'  
    ])  
  } catch (err) {  
    console.error('权限申请失败', err)  
  }  
}  
// #endif

2.2.2 数据结构设计

闹钟数据结构设计如下:

{  
  time: '08:00',              // 时间  
  label: '起床',              // 标签  
  repeat: [false, true, ...], // 重复(周日到周六)  
  ringtone: 0,                // 铃声索引  
  vibrate: true,              // 振动  
  gradualVolume: true,        // 音量渐增  
  snooze: 10,                 // 贪睡时长(分钟)  
  enabled: true               // 是否启用  
}

2.2.3 鸿蒙本地存储实现

uni.storage 在鸿蒙平台会自动使用 Preferences API,提供高性能的键值对存储:

methods: {  
  // 加载闹钟  
  loadAlarms() {  
    const saved = uni.getStorageSync('alarms')  
    if (saved) {  
      this.alarms = JSON.parse(saved)  
    }  
  },  

  // 保存闹钟  
  saveAlarms() {  
    uni.setStorageSync('alarms', JSON.stringify(this.alarms))  
  },  

  // 添加/编辑闹钟  
  saveAlarm() {  
    if (this.editIndex >= 0) {  
      // 编辑现有闹钟  
      this.alarms[this.editIndex] = this.editAlarmData  
    } else {  
      // 添加新闹钟  
      this.alarms.push(this.editAlarmData)  
    }  
    this.saveAlarms()  
    this.closeDialog()  
  },  

  // 删除闹钟  
  deleteAlarm() {  
    uni.showModal({  
      title: '确认删除',  
      content: '确定要删除这个闹钟吗?',  
      success: (res) => {  
        if (res.confirm) {  
          this.alarms.splice(this.editIndex, 1)  
          this.saveAlarms()  
        }  
      }  
    })  
  }  
}

2.2.4 鸿蒙通知发送

使用鸿蒙原生通知 API 发送闹钟提醒:

// #ifdef APP-HARMONY  
import notificationManager from '@ohos.notificationManager'  

// 发送鸿蒙通知  
async function sendAlarmNotification(alarmData) {  
  const notificationRequest = {  
    id: alarmData.id,  
    content: {  
      contentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,  
      normal: {  
        title: '闹钟提醒',  
        text: alarmData.label || '该起床了!',  
        additionalText: new Date().toLocaleTimeString()  
      }  
    },  
    actionButtons: [  
      {  
        title: '贪睡',  
        wantAgent: createSnoozeWantAgent()  
      },  
      {  
        title: '关闭',  
        wantAgent: createCloseWantAgent()  
      }  
    ]  
  }  

  try {  
    await notificationManager.publish(notificationRequest)  
  } catch (err) {  
    console.error('通知发送失败', err)  
  }  
}  
// #endif  

// 跨平台兼容写法  
function sendNotification(alarmData) {  
  // #ifdef APP-HARMONY  
  sendAlarmNotification(alarmData)  
  // #endif  

  // #ifndef APP-HARMONY  
  uni.showModal({  
    title: '闹钟提醒',  
    content: alarmData.label || '该起床了!'  
  })  
  // #endif  
}

2.2.5 鸿蒙振动反馈

// #ifdef APP-HARMONY  
import vibrator from '@ohos.vibrator'  

// 鸿蒙振动效果  
function vibrateHarmony() {  
  try {  
    // 预定义振动效果  
    vibrator.startVibration({  
      type: 'preset',  
      effectId: 'haptic.clock.timer',  
      count: 3  
    })  

    // 或自定义振动  
    vibrator.startVibration({  
      type: 'time',  
      duration: 1000  
    })  
  } catch (err) {  
    console.error('振动失败', err)  
  }  
}  
// #endif  

// 跨平台振动封装  
function triggerVibration() {  
  // #ifdef APP-HARMONY  
  vibrateHarmony()  
  // #endif  

  // #ifndef APP-HARMONY  
  uni.vibrateShort()  
  // #endif  
}

2.2.6 弹窗表单设计

使用自定义弹窗组件实现闹钟编辑界面:

<view v-if="showDialog" class="dialog-mask" @click="closeDialog">  
  <view class="dialog" @click.stop>  
    <view class="dialog-header">  
      <text class="dialog-title">  
        {{editIndex >= 0 ? '编辑闹钟' : '添加闹钟'}}  
      </text>  
    </view>  

    <view class="dialog-content">  
      <!-- 时间选择器 -->  
      <picker mode="time" :value="editAlarmData.time"   
              @change="onTimeChange">  
        <text>{{editAlarmData.time}}</text>  
      </picker>  

      <!-- 周重复选择 -->  
      <view class="week-selector">  
        <text v-for="(day, i) in weekDays" :key="i"   
              :class="{'active': editAlarmData.repeat[i]}"  
              @click="toggleDay(i)">  
          {{day}}  
        </text>  
      </view>  
    </view>  
  </view>  
</view>

2.3 秒表页面 - 高精度计时与圈数记录

2.3.1 高精度计时实现

使用 10ms 间隔实现百分之一秒精度:

export default {  
  data() {  
    return {  
      isRunning: false,  
      totalTime: 0,      // 总时间(毫秒)  
      startTime: 0,  
      timer: null,  
      laps: []           // 圈数记录  
    }  
  },  
  computed: {  
    formattedTime() {  
      const totalSeconds = Math.floor(this.totalTime / 1000)  
      const minutes = Math.floor(totalSeconds / 60)  
      const seconds = totalSeconds % 60  
      return (minutes < 10 ? '0' : '') + minutes + ':' +   
             (seconds < 10 ? '0' : '') + seconds  
    },  
    milliseconds() {  
      return String(Math.floor((this.totalTime % 1000) / 10))  
        .padStart(2, '0')  
    }  
  },  
  methods: {  
    start() {  
      this.isRunning = true  
      this.startTime = Date.now() - this.totalTime  
      this.timer = setInterval(() => {  
        this.totalTime = Date.now() - this.startTime  
      }, 10)  // 10ms 更新一次  
    }  
  }  
}

2.3.2 圈数记录与排序

实现圈数记录和多种排序方式:

methods: {  
  recordLap() {  
    const lapTime = this.totalTime - this.lastLapTime  
    this.lapCounter++  

    this.laps.unshift({  
      id: Date.now(),  
      index: this.lapCounter,  
      time: this.formatTime(lapTime),  
      rawTime: lapTime  
    })  

    this.lastLapTime = this.totalTime  
  }  
},  
computed: {  
  sortedLaps() {  
    let sorted = [...this.laps]  

    // 根据排序模式排序  
    if (this.sortMode === 'fastest') {  
      sorted.sort((a, b) => a.rawTime - b.rawTime)  
    } else if (this.sortMode === 'slowest') {  
      sorted.sort((a, b) => b.rawTime - a.rawTime)  
    }  

    // 标记最快和最慢  
    if (sorted.length > 1) {  
      const times = this.laps.map(l => l.rawTime)  
      const fastest = Math.min(...times)  
      const slowest = Math.max(...times)  

      sorted = sorted.map(lap => ({  
        ...lap,  
        isFastest: lap.rawTime === fastest,  
        isSlowest: lap.rawTime === slowest && fastest !== slowest  
      }))  
    }  

    return sorted  
  }  
}

2.4 计时器页面 - 倒计时与进度显示

2.4.1 时间选择器实现

使用 picker-view 实现时间选择:

<view class="picker-row">  
  <view class="picker-group">  
    <picker-view class="picker" :value="[hours]"   
                 @change="onHoursChange">  
      <picker-view-column>  
        <view v-for="n in 24" :key="n" class="picker-item">  
          <text>{{n - 1}}</text>  
        </view>  
      </picker-view-column>  
    </picker-view>  
    <text class="picker-label">小时</text>  
  </view>  

  <!-- 分钟和秒的选择器类似 -->  
</view>

2.4.2 进度条实现

实时计算并显示倒计时进度:

computed: {  
  progressPercent() {  
    if (this.totalSeconds === 0) return 0  
    return Math.floor(  
      (this.totalSeconds - this.remainingTime) /   
      this.totalSeconds * 100  
    )  
  }  
}
<view class="progress-bar">  
  <view class="progress-fill"   
        :style="{width: progressPercent + '%'}">  
  </view>  
</view>

2.4.3 倒计时完成处理

onTimerComplete() {  
  this.stopTimer()  
  this.isRunning = false  

  // 振动提醒  
  if (this.vibrate) {  
    // uni.vibrateLong()  
  }  

  // 弹窗通知  
  if (this.notification) {  
    uni.showModal({  
      title: '计时完成',  
      content: '计时器时间已到!',  
      showCancel: false  
    })  
  }  
}

2.5 设置页面 - 丰富的个性化选项

2.5.1 屏幕常亮功能

onKeepScreenOnChange(e) {  
  this.keepScreenOn = e.detail.value  
  uni.setStorageSync('keepScreenOn', this.keepScreenOn)  

  uni.setKeepScreenOn({  
    keepScreenOn: this.keepScreenOn  
  })  

  uni.showToast({  
    title: this.keepScreenOn ?   
      '屏幕常亮已开启' : '屏幕常亮已关闭',  
    icon: 'none'  
  })  
}

2.5.2 数据清除功能

clearData() {  
  uni.showModal({  
    title: '确认清除',  
    content: '此操作将清除所有闹钟和设置数据,无法恢复。确定要继续吗?',  
    confirmText: '清除',  
    confirmColor: '#F44336',  
    success: (res) => {  
      if (res.confirm) {  
        uni.clearStorageSync()  
        uni.showToast({  
          title: '数据已清除',  
          icon: 'success'  
        })  
        // 重启应用  
        setTimeout(() => {  
          uni.reLaunch({  
            url: '/pages/index/index'  
          })  
        }, 1500)  
      }  
    }  
  })  
}

三、UI/UX 设计与优化

3.1 Material Design 样式实现

3.1.1 卡片阴影效果

.card {  
  background-color: #2C2C2C;  
  border-radius: 8px;  
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);  
}

3.1.2 悬浮按钮(FAB)

.add-btn {  
  position: fixed;  
  right: 20px;  
  bottom: 100px;  
  width: 60px;  
  height: 60px;  
  background-color: #FF6B00;  
  border-radius: 30px;  
  display: flex;  
  justify-content: center;  
  align-items: center;  
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);  
}

3.2 颜色系统

深色主题配色方案:

元素 颜色值 用途
主色 #FF6B00 按钮、选中状态、导航栏
背景 #212121 页面背景
卡片 #2C2C2C 列表项、卡片背景
分隔 #3A3A3A 边框、分隔线
文本主 #FFFFFF 主要文本
文本次 #CCCCCC 次要文本
文本禁 #999999 禁用文本

3.3 交互优化

3.3.1 空状态设计

<view v-if="alarms.length === 0" class="empty-state">  
  <text class="empty-text">暂无闹钟</text>  
  <text class="empty-hint">点击下方 "+" 按钮添加闹钟</text>  
</view>

3.3.2 加载状态与反馈

// 操作成功提示  
uni.showToast({  
  title: '保存成功',  
  icon: 'success'  
})  

// 确认对话框  
uni.showModal({  
  title: '确认删除',  
  content: '确定要删除这个闹钟吗?',  
  success: (res) => {  
    if (res.confirm) {  
      // 执行删除  
    }  
  }  
})

四、性能优化

4.1 计时器管理

确保在页面卸载时清除定时器,避免内存泄漏:

export default {  
  onUnload() {  
    this.stopTimer()  
  },  
  methods: {  
    stopTimer() {  
      if (this.timer) {  
        clearInterval(this.timer)  
        this.timer = null  
      }  
    }  
  }  
}

4.2 数据缓存策略

  • 使用 uni.setStorageSync 同步存储关键数据
  • 页面加载时从本地读取数据,减少计算
  • 数据变更时立即保存,确保数据一致性

4.3 列表渲染优化

使用 :key 提升列表渲染性能:

<view v-for="(alarm, index) in alarms"   
      :key="alarm.id || index"   
      class="alarm-item">  
  <!-- 内容 -->  
</view>

五、鸿蒙平台深度适配

5.1 鸿蒙返回键处理

鸿蒙系统的返回键需要特殊处理,实现双击退出:

// #ifdef APP-HARMONY  
let firstBackTime = 0  

export default {  
  onLastPageBackPress: function () {  
    console.log('HarmonyOS 返回键按下')  
    if (firstBackTime == 0) {  
      uni.showToast({  
        title: '再按一次退出应用',  
        position: 'bottom',  
      })  
      firstBackTime = Date.now()  
      setTimeout(() => {  
        firstBackTime = 0  
      }, 2000)  
    } else if (Date.now() - firstBackTime < 2000) {  
      uni.exit() // 退出应用  
    }  
  }  
}  
// #endif

5.2 鸿蒙屏幕常亮

利用鸿蒙的电源管理 API 实现屏幕常亮:

// #ifdef APP-HARMONY  
import { runningLock } from '@kit.BasicServicesKit'  

let screenLock = null  

// 保持屏幕常亮  
function keepScreenOn() {  
  try {  
    screenLock = runningLock.createRunningLock('screen',   
      runningLock.RunningLockType.RUNNINGLOCK_SCREEN)  
    screenLock.lock(0) // 0 表示永久锁定  
  } catch (err) {  
    console.error('屏幕常亮失败', err)  
  }  
}  

// 释放屏幕锁  
function releaseScreenLock() {  
  if (screenLock) {  
    screenLock.unlock()  
    screenLock = null  
  }  
}  
// #endif  

// 跨平台封装  
function setKeepScreenOn(keep) {  
  // #ifdef APP-HARMONY  
  if (keep) {  
    keepScreenOn()  
  } else {  
    releaseScreenLock()  
  }  
  // #endif  

  // #ifndef APP-HARMONY  
  uni.setKeepScreenOn({ keepScreenOn: keep })  
  // #endif  
}

5.3 鸿蒙后台任务

实现闹钟的后台运行:

// #ifdef APP-HARMONY  
import backgroundTaskManager from '@ohos.resourceschedule.backgroundTaskManager'  

// 申请长时任务  
async function requestBackgroundTask() {  
  try {  
    await backgroundTaskManager.requestSuspendDelay('时钟后台任务', () => {  
      console.log('后台任务即将被挂起')  
      // 保存状态  
    })  
  } catch (err) {  
    console.error('后台任务申请失败', err)  
  }  
}  
// #endif

5.4 鸿蒙安全区域适配

考虑鸿蒙设备的刘海屏、水滴屏和折叠屏:

.container {  
  // 鸿蒙安全区域  
  padding-top: env(safe-area-inset-top);  
  padding-bottom: env(safe-area-inset-bottom);  
  padding-left: env(safe-area-inset-left);  
  padding-right: env(safe-area-inset-right);  
}  

// 鸿蒙折叠屏适配  
@media screen and (min-width: 600px) {  
  .container {  
    max-width: 600px;  
    margin: 0 auto;  
  }  
}

5.5 鸿蒙生命周期适配

export default {  
  // 应用前台  
  onShow() {  
    // #ifdef APP-HARMONY  
    console.log('HarmonyOS 应用进入前台')  
    this.resumeTimers() // 恢复计时器  
    // #endif  
  },  

  // 应用后台  
  onHide() {  
    // #ifdef APP-HARMONY  
    console.log('HarmonyOS 应用进入后台')  
    this.pauseTimers() // 暂停计时器  
    this.saveState() // 保存状态  
    // #endif  
  }  
}

5.6 鸿蒙深色模式适配

鸿蒙系统的深色模式可以自动切换:

// #ifdef APP-HARMONY  
import { configuration } from '@kit.AbilityKit'  

// 监听系统主题变化  
function watchThemeChange() {  
  const config = configuration.getConfiguration()  
  const isDark = config.colorMode === configuration.ColorMode.COLOR_MODE_DARK  

  // 应用深色主题  
  if (isDark) {  
    this.applyDarkTheme()  
  } else {  
    this.applyLightTheme()  
  }  
}  
// #endif

六、鸿蒙应用测试与调试

6.1 鸿蒙开发者模式

在鸿蒙设备上启用开发者模式:

  1. 进入"设置" → "关于手机"
  2. 连续点击"版本号"7次
  3. 返回"设置" → "系统和更新" → "开发者选项"
  4. 开启"USB 调试"和"USB 安装"

6.2 HBuilderX 真机调试

# 1. 连接鸿蒙设备  
# 2. 在 HBuilderX 中选择运行设备  
# 3. 点击"运行" → "运行到手机或模拟器" → "运行到 HarmonyOS"  

# 查看日志  
hdc shell hilog | grep "simpleclock"

6.3 鸿蒙特有功能测试清单

  • [ ] 通知权限:闹钟通知是否正常显示
  • [ ] 振动权限:振动反馈是否生效
  • [ ] 后台任务:应用后台时闹钟是否正常工作
  • [ ] 屏幕常亮:前台运行时屏幕是否保持常亮
  • [ ] 数据持久化:应用重启后数据是否保留
  • [ ] 深色模式:系统切换主题时应用是否自动适配
  • [ ] 折叠屏适配:在折叠屏设备上布局是否正常
  • [ ] 返回键处理:双击返回是否正常退出

6.4 性能测试(鸿蒙平台)

// 使用鸿蒙性能追踪  
// #ifdef APP-HARMONY  
import hiTraceMeter from '@ohos.hiTraceMeter'  

// 开始性能追踪  
hiTraceMeter.startTrace('stopwatch_timing', 1001)  

// 执行计时逻辑  
this.updateStopwatchTime()  

// 结束追踪  
hiTraceMeter.finishTrace('stopwatch_timing', 1001)  
// #endif

6.2 边界情况处理

// 防止重复点击  
if (this.totalSeconds === 0) {  
  uni.showToast({  
    title: '请设置计时时间',  
    icon: 'none'  
  })  
  return  
}  

// 数据验证  
if (!editAlarmData.time) {  
  uni.showToast({  
    title: '请选择时间',  
    icon: 'none'  
  })  
  return  
}

七、项目总结

7.1 技术亮点

  1. 完整的功能闭环:从时间显示到提醒管理,覆盖用户全部需求
  2. 优雅的代码结构:模块化设计,职责清晰
  3. 良好的用户体验:Material Design + 深色主题
  4. 高性能实现:合理使用定时器,优化渲染性能
  5. 跨平台兼容:一套代码,多端运行

7.2 可扩展方向

  1. 云同步功能:支持多设备数据同步
  2. 更多铃声:支持自定义铃声上传
  3. 统计分析:记录使用习惯,提供数据分析
  4. 智能提醒:根据用户习惯智能推荐闹钟时间
  5. 主题定制:支持更多颜色主题

7.3 开发心得

  1. 用户体验至上:每一个交互细节都要仔细打磨
  2. 代码质量:保持代码简洁,注重可维护性
  3. 性能优化:及时清理资源,避免内存泄漏
  4. 测试驱动:充分测试各种边界情况
  5. 持续迭代:根据用户反馈不断优化改进

八、源码与资源

8.1 项目结构

simpleclock/  
├── pages/  
│   ├── index/index.uvue      # 264 行  
│   ├── alarm/alarm.uvue      # 430 行  
│   ├── stopwatch/stopwatch.uvue  # 280 行  
│   ├── timer/timer.uvue      # 300 行  
│   └── settings/settings.uvue    # 350 行  
├── App.uvue                  # 124 行  
├── pages.json                # 76 行  
├── uni.scss                  # 77 行  
├── README.md                 # 项目说明  
└── 使用指南.md               # 用户手册

8.2 代码统计

  • 总代码量:约 1900+ 行
  • 页面数:5 个主要页面
  • 组件数:多个自定义组件
  • 功能模块:4 个核心功能

8.3 运行环境

  • 开发工具:HBuilderX 4.0+
  • uni-app x 版本:最新版
  • 鸿蒙系统:HarmonyOS 6
  • 鸿蒙 SDK:API Level 10+
  • 测试设备:
    • 华为 Mate 60 系列(HarmonyOS 6)
    • 华为 MatePad Pro(折叠屏测试)
    • 鸿蒙模拟器

8.4 鸿蒙应用发布

8.4.1 应用签名

# 在华为开发者联盟申请应用签名  
# 下载签名文件并配置到 manifest.json  

{  
  "uni-app-x": {  
    "harmony": {  
      "signing": {  
        "profile": "path/to/profile.p7b",  
        "certFile": "path/to/cert.pem",  
        "keyFile": "path/to/key.p12",  
        "keyPassword": "your_password"  
      }  
    }  
  }  
}

8.4.2 打包发布

# HBuilderX 中操作:  
# 1. 发行 → 原生App-云打包  
# 2. 选择 HarmonyOS 平台  
# 3. 填写应用信息和签名配置  
# 4. 点击打包,等待云端编译  
# 5. 下载 .hap 安装包  

# 命令行打包(可选)  
npm run build:app-harmony

8.4.3 应用上架

  1. 登录华为开发者联盟
  2. 进入"应用服务" → "AppGallery Connect"
  3. 创建应用并上传 .hap 包
  4. 填写应用信息(截图、描述、分类)
  5. 提交审核
  6. 审核通过后上架

九、鸿蒙开发常见问题

9.1 权限被拒绝

问题:应用无法发送通知或振动

解决

// 在应用启动时主动请求权限  
// #ifdef APP-HARMONY  
async function checkAndRequestPermissions() {  
  const permissions = [  
    'ohos.permission.NOTIFICATION_CONTROLLER',  
    'ohos.permission.VIBRATE'  
  ]  

  for (let permission of permissions) {  
    const result = await checkPermission(permission)  
    if (!result) {  
      await requestPermission(permission)  
    }  
  }  
}  
// #endif

9.2 后台任务被终止

问题:闹钟在后台不响

解决:申请长时任务授权,确保应用在后台持续运行

9.3 数据丢失

问题:应用卸载后数据丢失

解决:使用鸿蒙云空间同步数据(需要用户登录华为账号)

9.4 性能问题

问题:页面切换卡顿

解决

  • 使用虚拟列表优化长列表渲染
  • 避免在主线程执行耗时操作
  • 使用鸿蒙的 Worker 线程处理复杂计算

结语

本文详细介绍了如何使用 uni-app x 开发一款功能完整的鸿蒙6原生时钟应用,从环境搭建、核心功能实现、鸿蒙6平台深度适配到应用发布,涵盖了鸿蒙6应用开发的各个方面。

🎉 这是一个真实的鸿蒙6原生应用开发案例!

核心要点回顾

  1. uni-app x 的优势

    • ✅ 编译为鸿蒙原生代码,性能卓越
    • ✅ 一套代码多端运行,大幅降低开发成本
    • ✅ 丰富的 API 支持,无缝对接鸿蒙能力
  2. 鸿蒙平台特性

    • ✅ 强大的通知系统
    • ✅ 完善的权限管理
    • ✅ 优秀的后台任务机制
    • ✅ 深度的系统集成
  3. 开发建议

    • 💡 充分利用条件编译,针对鸿蒙做优化
    • 💡 注意权限申请和用户体验
    • 💡 重视性能优化和内存管理
    • 💡 遵循鸿蒙设计规范

希望这篇文章能帮助你:

  • 🎯 零基础入门:从环境搭建到应用发布的完整流程
  • 🎨 深度理解鸿蒙6:掌握鸿蒙6的核心特性和设计理念
  • 💡 实战经验:学习时间管理类应用的最佳实践
  • 🚀 快速上手:30分钟开发出第一个鸿蒙6应用
  • 🔥 抓住机遇:在鸿蒙生态爆发期占据先机

下一步计划

  • [ ] 适配鸿蒙折叠屏设备
  • [ ] 接入华为账号系统
  • [ ] 实现华为云空间数据同步
  • [ ] 添加鸿蒙小部件(Widget)
  • [ ] 接入鸿蒙分布式能力

如果你有任何问题或建议,欢迎交流讨论!让我们一起为鸿蒙生态贡献力量!🚀


关键词:#鸿蒙6 #HarmonyOS6 #uni-app-x #纯血鸿蒙 #原生应用开发 #Vue3 #跨平台开发 #时钟应用

作者:坚果

日期:2025年10月28日
项目地址GitCode - simpleclock
开源协议:MIT License


📢 开源说明

本项目完全开源,欢迎:

  • ⭐ Star 项目,关注后续更新
  • 🍴 Fork 项目,二次开发
  • 🐛 提交 Issue,反馈问题
  • 🤝 提交 PR,贡献代码

当前状态

  • ✅ 核心功能已完成
  • ⚠️ 部分细节待优化(如时区同步、铃声播放等)
  • 🎯 持续迭代中,欢迎贡献代码!

🔗 相关资源

💬 交流讨论

如果你在开发过程中遇到问题,或有好的想法和建议:

让我们一起推动鸿蒙生态发展! 🚀🇨🇳

收起阅读 »

【鸿蒙征文】使用 uni-app 开发鸿蒙 App,如何实现 H5 网页和 App 互通讯?

鸿蒙征文

导语:打通 Web 与 Native 的“任督二脉”

在使用 uni-app 跨端开发时,我们经常需要在 App 内嵌一个 H5 页面(比如活动页、复杂表单页等)。

在鸿蒙(HarmonyOS)应用中,要让这个内嵌的 Web 页面App 的原生能力(Native)进行顺畅的沟通,就需要一个桥梁

本文将基于 UTS 插件,在鸿蒙平台上搭建起 H5 和 App 之间的双向通信机制,实现数据的传递和方法的互调。

🎯 核心目标:实现双向通信

我们最终要实现的效果是:

  1. App -> H5: App 侧可以调用 H5 页面中的 JavaScript 方法,比如向 H5 传递用户 ID 或登录 Token。
  2. H5 -> App: H5 页面可以调用 App 侧提供的原生方法,比如调用相册、获取地理位置,或本例中的“App 弹出提示”。

一、 App 侧:创建 UTS 插件并封装原生能力

在鸿蒙平台上,我们需要通过 UTS 插件来提供 App 侧供 H5 调用的原生 API。

1. 创建 UTS 插件结构

  • 在 uni-app 项目的 uni_modules 目录下,新建一个 UTS 插件,命名为 web-bridge-ohos
  • 确认插件目录结构如下:
    uni_modules/web-bridge-ohos/  
    ├── app-harmony/  
    │   └── index.uts   
    ├── package.json  
    └── interface.uts  

2. 编写 UTS 原生代码 (index.uts)

我们要实现一个核心类,这个类将作为 H5 和 App 通信的桥梁。在鸿蒙中,内嵌网页控件是 WebComponent。我们需要为它设置一个 WebMessagePort 监听器。

在本例中,我们创建一个名为 WebInteractionModule 的类,它负责处理 H5 发送过来的消息。

// 文件路径:uni_modules/web-bridge-ohos/app-harmony/index.uts  

import web_webview from '@ohos.web.webview';  
import hilog from '@ohos.hilog';  
import UIAbility from '@ohos.app.ability.UIAbility';  

const LOG_TAG: string = 'WebBridge';  

/**  
 * 【核心类】Web 交互模块  
 * 负责在鸿蒙平台上注入 Web 消息处理器  
 */  
export class WebInteractionModule {  
  private webViewComponent: web_webview.WebComponent | null = null;  

  /**  
   * 构造函数:初始化时可传入 WebComponent 实例  
   * @param webComp WebComponent 实例  
   */  
  constructor(webComp: web_webview.WebComponent) {  
    this.webViewComponent = webComp;  
    hilog.info(0x0001, LOG_TAG, `WebInteractionModule 初始化成功.`);  
  }  

  /**  
   * 步骤 1: 启用 H5 与 App 互通能力,设置消息监听器。  
   * @param bridgeName 桥接对象名称,H5 侧将使用此名称调用 App 方法。  
   * @param appHandler App 侧处理 H5 消息的回调函数。  
   */  
  public setupBridge(bridgeName: string, appHandler: (message: string) => void): void {  
    if (!this.webViewComponent) return;  

    // 1. 设置 Web 消息处理回调  
    this.webViewComponent.onWebMessage = (data: string) => {  
      hilog.info(0x0001, LOG_TAG, `收到 H5 消息: ${data}`);  
      // 2. 将收到的消息交给 App 业务层处理  
      appHandler(data);  
    };  

    // 3. 注入鸿蒙内置的消息通道对象(关键步骤)  
    // H5 侧将通过 window.[bridgeName].postMessage('data') 来调用  
    this.webViewComponent.injectWebMessagePort(bridgeName);  

    hilog.info(0x0001, LOG_TAG, `已注入 Web 桥接对象: ${bridgeName}`);  
  }  

  /**  
   * 步骤 2: 从 App 侧调用 H5 页面内的 JavaScript 方法。  
   * @param jsCode 要执行的 JavaScript 代码(如:`window.myH5Method('Hello from App')`)。  
   */  
  public callH5Function(jsCode: string): void {  
    if (this.webViewComponent) {  
      this.webViewComponent.executeJs(jsCode, (result) => {  
        hilog.info(0x0001, LOG_TAG, `执行 H5 JS 代码结果: ${result}`);  
      });  
    }  
  }  
}  

// =======================================================  
// 为了方便 uni-app 调用,我们提供一个 UTS 导出的工厂函数  
// =======================================================  

/**  
 * 导出工厂函数:创建并返回 WebInteractionModule 实例  
 * @param webComp H5 视图的 WebComponent 实例  
 * @returns WebInteractionModule 实例  
 */  
export function createWebBridge(webComp: web_webview.WebComponent): WebInteractionModule {  
  return new WebInteractionModule(webComp);  
}

3. UTS 接口定义 (interface.uts)

为了让上层 Vue/JS 代码能顺利使用这个类,我们需要定义接口:

// 文件路径:uni_modules/web-bridge-ohos/interface.uts  

import web_webview from '@ohos.web.webview';  

/**  
 * WebInteractionModule 类的接口定义  
 * 供 uni-app TypeScript/JavaScript 代码使用  
 */  
export interface WebInteractionModule {  
  /**  
   * 启动 App 和 H5 之间的通信桥接。  
   * @param bridgeName H5 侧调用的对象名 (如:'HarmonyAppBridge')  
   * @param appHandler App 侧接收 H5 消息的回调函数。  
   */  
  setupBridge(bridgeName: string, appHandler: (message: string) => void): void;  

  /**  
   * 从 App 侧调用 H5 页面中的 JavaScript 代码。  
   * @param jsCode 要在 H5 中执行的 JS 字符串。  
   */  
  callH5Function(jsCode: string): void;  
}  

/**  
 * UTS 导出函数接口:创建 Web 桥接模块实例  
 * @param webComp WebComponent 实例  
 * @returns WebInteractionModule 实例  
 */  
export type CreateWebBridge = (webComp: web_webview.WebComponent) => WebInteractionModule;

二、 App 侧:在 uni-app 页面中集成 WebComponent

在 uni-app 页面中,我们需要使用自定义组件的方式,引入鸿蒙平台专有的 web-view 组件,并调用 UTS 插件。

1. 页面逻辑 (pages/index/index.vue)

我们创建一个页面,其中包含一个内嵌的 web-view 组件,并实现 H5 消息处理逻辑。

<template>  
  <view class="container">  
    <text class="title">鸿蒙 Web-Native 互通演示</text>  

    <button @click="sendDataToH5">App 发送数据给 H5</button>  

    <view class="message-box">  
      <text>收到 H5 消息: {{ lastH5Message }}</text>  
    </view>  

    <web-view   
      ref="h5WebView"   
      class="h5-content"   
      :src="h5Url"   
      @onHarmonyWebComponentCreated="onWebCreated"   
    ></web-view>  
  </view>  
</template>  

<script lang="uts">  
  import { WebInteractionModule, createWebBridge } from '@/uni_modules/web-bridge-ohos';  
  import web_webview from '@ohos.web.webview';  

  // 【注意】请替换成你实际 H5 文件的网络地址或本地路径  
  const H5_PAGE_URL: string = 'https://yourdomain.com/path/to/h5/index.html';   
  const BRIDGE_OBJECT_NAME: string = 'HarmonyAppBridge'; // 供 H5 调用的对象名  

  export default {  
    data() {  
      return {  
        h5Url: H5_PAGE_URL,  
        lastH5Message: '暂无消息',  
        webBridge: null as WebInteractionModule | null, // 存储 UTS 桥接模块实例  
        sendCount: 0,  
      }  
    },  
    methods: {  
      /**  
       * 鸿蒙平台 WebComponent 创建成功后触发的事件  
       * @param e 事件对象,包含了 WebComponent 的实例  
       */  
      onWebCreated(e: { component: web_webview.WebComponent }) {  
        console.log('WebComponent 实例已创建');  
        const webComponent = e.component;  

        // 1. 创建 UTS 桥接模块实例  
        this.webBridge = createWebBridge(webComponent);  

        // 2. 设置桥接并注册 App 侧的消息处理器  
        this.webBridge.setupBridge(BRIDGE_OBJECT_NAME, this.handleMessageFromH5);  
      },  

      /**  
       * App 侧处理 H5 页面发送过来的消息  
       * @param message H5 传递过来的 JSON 字符串  
       */  
      handleMessageFromH5(message: string): void {  
        console.log(`App 收到 H5 消息: ${message}`);  
        this.lastH5Message = message;  

        // 示例:App 收到 H5 消息后,可以调用 App 的原生能力,比如 uni.showToast  
        uni.showToast({  
          title: `H5 说: ${message}`,  
          icon: 'none',  
        });  
      },  

      /**  
       * App 侧主动调用 H5 页面中的 JavaScript 方法  
       */  
      sendDataToH5(): void {  
        if (!this.webBridge) {  
          console.error('Web 桥接尚未初始化!');  
          uni.showToast({ title: '桥接未就绪', icon: 'error' });  
          return;  
        }  

        this.sendCount++;  
        const dataToSend = `{"action":"updateUser","userId":1000${this.sendCount}}`;  

        // 构造要执行的 H5 JS 代码  
        // 假设 H5 页面中有一个全局函数名为 `receiveAppMessage`  
        const jsCode: string = `window.receiveAppMessage('${dataToSend}')`;  

        // 调用 UTS 插件方法执行 JS  
        this.webBridge.callH5Function(jsCode);  
      }  
    }  
  }  
</script>  

<style>  
  .container {  
    padding: 20px;  
  }  
  .title {  
    font-size: 18px;  
    font-weight: bold;  
    margin-bottom: 20px;  
    display: block;  
  }  
  .message-box {  
    margin: 15px 0;  
    padding: 10px;  
    border: 1px solid #ddd;  
    background-color: #f0f0f0;  
  }  
  .h5-content {  
    /* 确保 web-view 有足够的高度显示 */  
    width: 100%;  
    height: 300px;   
    margin-top: 20px;  
    border: 1px solid #ccc;  
  }  
</style>

三、 H5 侧:实现双向通信逻辑

H5 页面需要实现两套机制:一套用于接收 App 消息,一套用于发送消息给 App。

1. H5 接收 App 消息的函数

App 侧会调用我们在 H5 中定义的全局函数 window.receiveAppMessage

<script>  
  // 【接收】App -> H5  
  window.receiveAppMessage = function(dataStr) {  
    console.log('H5 收到 App 消息:', dataStr);  
    try {  
      const data = JSON.parse(dataStr);  
      document.getElementById('app-msg').innerText = '最新 App 消息: ' + data.action + ',用户ID:' + data.userId;  
    } catch (e) {  
      document.getElementById('app-msg').innerText = '收到非法消息格式: ' + dataStr;  
    }  
  };  
</script>

2. H5 发送消息给 App 的方法

H5 通过调用 App 侧注入的桥接对象(我们命名为 HarmonyAppBridge)上的 postMessage 方法来发送消息。

<script>  
  // 【发送】H5 -> App  
  function sendToApp(message) {  
    // 检查 App 侧注入的桥接对象是否存在  
    if (window.HarmonyAppBridge && typeof window.HarmonyAppBridge.postMessage === 'function') {  
      window.HarmonyAppBridge.postMessage(message);  
      document.getElementById('h5-status').innerText = '成功发送消息给 App: ' + message;  
    } else {  
      document.getElementById('h5-status').innerText = 'App 桥接对象 (HarmonyAppBridge) 不存在或未就绪!';  
      console.error('App 桥接对象不存在,无法发送消息。');  
    }  
  }  

  // 按钮点击事件示例  
  document.getElementById('send-btn').onclick = function() {  
    const time = new Date().toLocaleTimeString();  
    const message = `{"event":"h5Ready","time":"${time}"}`;  
    sendToApp(message);  
  };  
</script>  

<div>  
    <h1>H5 页面内容</h1>  
    <p id="app-msg">最新 App 消息: 暂无</p>  
    <button id="send-btn">H5 调用 App 弹窗</button>  
    <p id="h5-status"></p>  
</div>

总结

通过上述三个部分的协作,我们成功地在 uni-app 鸿蒙应用中搭建了 H5 页面和 App 之间的通信桥梁:

  1. App 侧 (UTS): 使用 WebInteractionModule 类和 WebComponent 的原生能力,通过 injectWebMessagePort 注入发送通道,通过 onWebMessage 接收 H5 消息。
  2. App 侧 (Vue): 使用 <web-view> 组件,并通过 onHarmonyWebComponentCreated 事件获取 WebComponent 实例,将其实例传递给 UTS 插件进行初始化和监听。
  3. H5 侧 (JS): 通过调用全局注入的 window.HarmonyAppBridge.postMessage() 发送消息给 App,并通过全局函数 window.receiveAppMessage() 接收 App 的指令。

全文完,欢迎点赞、收藏、转发!

继续阅读 »

导语:打通 Web 与 Native 的“任督二脉”

在使用 uni-app 跨端开发时,我们经常需要在 App 内嵌一个 H5 页面(比如活动页、复杂表单页等)。

在鸿蒙(HarmonyOS)应用中,要让这个内嵌的 Web 页面App 的原生能力(Native)进行顺畅的沟通,就需要一个桥梁

本文将基于 UTS 插件,在鸿蒙平台上搭建起 H5 和 App 之间的双向通信机制,实现数据的传递和方法的互调。

🎯 核心目标:实现双向通信

我们最终要实现的效果是:

  1. App -> H5: App 侧可以调用 H5 页面中的 JavaScript 方法,比如向 H5 传递用户 ID 或登录 Token。
  2. H5 -> App: H5 页面可以调用 App 侧提供的原生方法,比如调用相册、获取地理位置,或本例中的“App 弹出提示”。

一、 App 侧:创建 UTS 插件并封装原生能力

在鸿蒙平台上,我们需要通过 UTS 插件来提供 App 侧供 H5 调用的原生 API。

1. 创建 UTS 插件结构

  • 在 uni-app 项目的 uni_modules 目录下,新建一个 UTS 插件,命名为 web-bridge-ohos
  • 确认插件目录结构如下:
    uni_modules/web-bridge-ohos/  
    ├── app-harmony/  
    │   └── index.uts   
    ├── package.json  
    └── interface.uts  

2. 编写 UTS 原生代码 (index.uts)

我们要实现一个核心类,这个类将作为 H5 和 App 通信的桥梁。在鸿蒙中,内嵌网页控件是 WebComponent。我们需要为它设置一个 WebMessagePort 监听器。

在本例中,我们创建一个名为 WebInteractionModule 的类,它负责处理 H5 发送过来的消息。

// 文件路径:uni_modules/web-bridge-ohos/app-harmony/index.uts  

import web_webview from '@ohos.web.webview';  
import hilog from '@ohos.hilog';  
import UIAbility from '@ohos.app.ability.UIAbility';  

const LOG_TAG: string = 'WebBridge';  

/**  
 * 【核心类】Web 交互模块  
 * 负责在鸿蒙平台上注入 Web 消息处理器  
 */  
export class WebInteractionModule {  
  private webViewComponent: web_webview.WebComponent | null = null;  

  /**  
   * 构造函数:初始化时可传入 WebComponent 实例  
   * @param webComp WebComponent 实例  
   */  
  constructor(webComp: web_webview.WebComponent) {  
    this.webViewComponent = webComp;  
    hilog.info(0x0001, LOG_TAG, `WebInteractionModule 初始化成功.`);  
  }  

  /**  
   * 步骤 1: 启用 H5 与 App 互通能力,设置消息监听器。  
   * @param bridgeName 桥接对象名称,H5 侧将使用此名称调用 App 方法。  
   * @param appHandler App 侧处理 H5 消息的回调函数。  
   */  
  public setupBridge(bridgeName: string, appHandler: (message: string) => void): void {  
    if (!this.webViewComponent) return;  

    // 1. 设置 Web 消息处理回调  
    this.webViewComponent.onWebMessage = (data: string) => {  
      hilog.info(0x0001, LOG_TAG, `收到 H5 消息: ${data}`);  
      // 2. 将收到的消息交给 App 业务层处理  
      appHandler(data);  
    };  

    // 3. 注入鸿蒙内置的消息通道对象(关键步骤)  
    // H5 侧将通过 window.[bridgeName].postMessage('data') 来调用  
    this.webViewComponent.injectWebMessagePort(bridgeName);  

    hilog.info(0x0001, LOG_TAG, `已注入 Web 桥接对象: ${bridgeName}`);  
  }  

  /**  
   * 步骤 2: 从 App 侧调用 H5 页面内的 JavaScript 方法。  
   * @param jsCode 要执行的 JavaScript 代码(如:`window.myH5Method('Hello from App')`)。  
   */  
  public callH5Function(jsCode: string): void {  
    if (this.webViewComponent) {  
      this.webViewComponent.executeJs(jsCode, (result) => {  
        hilog.info(0x0001, LOG_TAG, `执行 H5 JS 代码结果: ${result}`);  
      });  
    }  
  }  
}  

// =======================================================  
// 为了方便 uni-app 调用,我们提供一个 UTS 导出的工厂函数  
// =======================================================  

/**  
 * 导出工厂函数:创建并返回 WebInteractionModule 实例  
 * @param webComp H5 视图的 WebComponent 实例  
 * @returns WebInteractionModule 实例  
 */  
export function createWebBridge(webComp: web_webview.WebComponent): WebInteractionModule {  
  return new WebInteractionModule(webComp);  
}

3. UTS 接口定义 (interface.uts)

为了让上层 Vue/JS 代码能顺利使用这个类,我们需要定义接口:

// 文件路径:uni_modules/web-bridge-ohos/interface.uts  

import web_webview from '@ohos.web.webview';  

/**  
 * WebInteractionModule 类的接口定义  
 * 供 uni-app TypeScript/JavaScript 代码使用  
 */  
export interface WebInteractionModule {  
  /**  
   * 启动 App 和 H5 之间的通信桥接。  
   * @param bridgeName H5 侧调用的对象名 (如:'HarmonyAppBridge')  
   * @param appHandler App 侧接收 H5 消息的回调函数。  
   */  
  setupBridge(bridgeName: string, appHandler: (message: string) => void): void;  

  /**  
   * 从 App 侧调用 H5 页面中的 JavaScript 代码。  
   * @param jsCode 要在 H5 中执行的 JS 字符串。  
   */  
  callH5Function(jsCode: string): void;  
}  

/**  
 * UTS 导出函数接口:创建 Web 桥接模块实例  
 * @param webComp WebComponent 实例  
 * @returns WebInteractionModule 实例  
 */  
export type CreateWebBridge = (webComp: web_webview.WebComponent) => WebInteractionModule;

二、 App 侧:在 uni-app 页面中集成 WebComponent

在 uni-app 页面中,我们需要使用自定义组件的方式,引入鸿蒙平台专有的 web-view 组件,并调用 UTS 插件。

1. 页面逻辑 (pages/index/index.vue)

我们创建一个页面,其中包含一个内嵌的 web-view 组件,并实现 H5 消息处理逻辑。

<template>  
  <view class="container">  
    <text class="title">鸿蒙 Web-Native 互通演示</text>  

    <button @click="sendDataToH5">App 发送数据给 H5</button>  

    <view class="message-box">  
      <text>收到 H5 消息: {{ lastH5Message }}</text>  
    </view>  

    <web-view   
      ref="h5WebView"   
      class="h5-content"   
      :src="h5Url"   
      @onHarmonyWebComponentCreated="onWebCreated"   
    ></web-view>  
  </view>  
</template>  

<script lang="uts">  
  import { WebInteractionModule, createWebBridge } from '@/uni_modules/web-bridge-ohos';  
  import web_webview from '@ohos.web.webview';  

  // 【注意】请替换成你实际 H5 文件的网络地址或本地路径  
  const H5_PAGE_URL: string = 'https://yourdomain.com/path/to/h5/index.html';   
  const BRIDGE_OBJECT_NAME: string = 'HarmonyAppBridge'; // 供 H5 调用的对象名  

  export default {  
    data() {  
      return {  
        h5Url: H5_PAGE_URL,  
        lastH5Message: '暂无消息',  
        webBridge: null as WebInteractionModule | null, // 存储 UTS 桥接模块实例  
        sendCount: 0,  
      }  
    },  
    methods: {  
      /**  
       * 鸿蒙平台 WebComponent 创建成功后触发的事件  
       * @param e 事件对象,包含了 WebComponent 的实例  
       */  
      onWebCreated(e: { component: web_webview.WebComponent }) {  
        console.log('WebComponent 实例已创建');  
        const webComponent = e.component;  

        // 1. 创建 UTS 桥接模块实例  
        this.webBridge = createWebBridge(webComponent);  

        // 2. 设置桥接并注册 App 侧的消息处理器  
        this.webBridge.setupBridge(BRIDGE_OBJECT_NAME, this.handleMessageFromH5);  
      },  

      /**  
       * App 侧处理 H5 页面发送过来的消息  
       * @param message H5 传递过来的 JSON 字符串  
       */  
      handleMessageFromH5(message: string): void {  
        console.log(`App 收到 H5 消息: ${message}`);  
        this.lastH5Message = message;  

        // 示例:App 收到 H5 消息后,可以调用 App 的原生能力,比如 uni.showToast  
        uni.showToast({  
          title: `H5 说: ${message}`,  
          icon: 'none',  
        });  
      },  

      /**  
       * App 侧主动调用 H5 页面中的 JavaScript 方法  
       */  
      sendDataToH5(): void {  
        if (!this.webBridge) {  
          console.error('Web 桥接尚未初始化!');  
          uni.showToast({ title: '桥接未就绪', icon: 'error' });  
          return;  
        }  

        this.sendCount++;  
        const dataToSend = `{"action":"updateUser","userId":1000${this.sendCount}}`;  

        // 构造要执行的 H5 JS 代码  
        // 假设 H5 页面中有一个全局函数名为 `receiveAppMessage`  
        const jsCode: string = `window.receiveAppMessage('${dataToSend}')`;  

        // 调用 UTS 插件方法执行 JS  
        this.webBridge.callH5Function(jsCode);  
      }  
    }  
  }  
</script>  

<style>  
  .container {  
    padding: 20px;  
  }  
  .title {  
    font-size: 18px;  
    font-weight: bold;  
    margin-bottom: 20px;  
    display: block;  
  }  
  .message-box {  
    margin: 15px 0;  
    padding: 10px;  
    border: 1px solid #ddd;  
    background-color: #f0f0f0;  
  }  
  .h5-content {  
    /* 确保 web-view 有足够的高度显示 */  
    width: 100%;  
    height: 300px;   
    margin-top: 20px;  
    border: 1px solid #ccc;  
  }  
</style>

三、 H5 侧:实现双向通信逻辑

H5 页面需要实现两套机制:一套用于接收 App 消息,一套用于发送消息给 App。

1. H5 接收 App 消息的函数

App 侧会调用我们在 H5 中定义的全局函数 window.receiveAppMessage

<script>  
  // 【接收】App -> H5  
  window.receiveAppMessage = function(dataStr) {  
    console.log('H5 收到 App 消息:', dataStr);  
    try {  
      const data = JSON.parse(dataStr);  
      document.getElementById('app-msg').innerText = '最新 App 消息: ' + data.action + ',用户ID:' + data.userId;  
    } catch (e) {  
      document.getElementById('app-msg').innerText = '收到非法消息格式: ' + dataStr;  
    }  
  };  
</script>

2. H5 发送消息给 App 的方法

H5 通过调用 App 侧注入的桥接对象(我们命名为 HarmonyAppBridge)上的 postMessage 方法来发送消息。

<script>  
  // 【发送】H5 -> App  
  function sendToApp(message) {  
    // 检查 App 侧注入的桥接对象是否存在  
    if (window.HarmonyAppBridge && typeof window.HarmonyAppBridge.postMessage === 'function') {  
      window.HarmonyAppBridge.postMessage(message);  
      document.getElementById('h5-status').innerText = '成功发送消息给 App: ' + message;  
    } else {  
      document.getElementById('h5-status').innerText = 'App 桥接对象 (HarmonyAppBridge) 不存在或未就绪!';  
      console.error('App 桥接对象不存在,无法发送消息。');  
    }  
  }  

  // 按钮点击事件示例  
  document.getElementById('send-btn').onclick = function() {  
    const time = new Date().toLocaleTimeString();  
    const message = `{"event":"h5Ready","time":"${time}"}`;  
    sendToApp(message);  
  };  
</script>  

<div>  
    <h1>H5 页面内容</h1>  
    <p id="app-msg">最新 App 消息: 暂无</p>  
    <button id="send-btn">H5 调用 App 弹窗</button>  
    <p id="h5-status"></p>  
</div>

总结

通过上述三个部分的协作,我们成功地在 uni-app 鸿蒙应用中搭建了 H5 页面和 App 之间的通信桥梁:

  1. App 侧 (UTS): 使用 WebInteractionModule 类和 WebComponent 的原生能力,通过 injectWebMessagePort 注入发送通道,通过 onWebMessage 接收 H5 消息。
  2. App 侧 (Vue): 使用 <web-view> 组件,并通过 onHarmonyWebComponentCreated 事件获取 WebComponent 实例,将其实例传递给 UTS 插件进行初始化和监听。
  3. H5 侧 (JS): 通过调用全局注入的 window.HarmonyAppBridge.postMessage() 发送消息给 App,并通过全局函数 window.receiveAppMessage() 接收 App 的指令。

全文完,欢迎点赞、收藏、转发!

收起阅读 »

【鸿蒙征文】使用 UTS 插件优雅实现“一键退出应用”功能

鸿蒙征文

随着 uni-app 对鸿蒙开发的支持日益完善,我们现在可以轻松地使用一套代码同时发布到多个平台。不过,在开发鸿蒙(HarmonyOS)应用时,有些平台特有的功能需要我们通过 UTS 插件来调用原生能力。

本文将手把手教你如何创建一个 UTS 插件,并利用鸿蒙系统提供的原生接口,为你的 uni-app 应用添加一个“一键安全退出”的功能。

一、 UTS 插件的创建与命名

首先,我们需要在 uni-app 项目中创建一个专用于封装鸿蒙原生 API 的 UTS 插件。

  1. 创建插件: 在 HBuilderX 中,找到你的项目目录下的 uni_modules 文件夹。
  2. 右键点击 uni_modules -> 新建插件
  3. 命名插件: 为了与退出应用功能相关联,我们将插件命名为 ohos-exit-helper
  4. 选择类型: 选择插件类型为 uts

创建完成后,你的项目结构会多出一个 uni_modules/ohos-exit-helper 目录。

二、 插件配置:在 uni 对象上注册新方法

接下来,我们需要修改插件的配置文件,告诉 uni-app 框架:我们要在全局的 uni 对象上注册一个名为 exitCurrentApp 的新方法。

打开 uni_modules/ohos-exit-helper/package.json 文件,找到 uni_modules 字段,并添加如下配置:

// 文件路径:uni_modules/ohos-exit-helper/package.json  

{  
  // ... 其他配置项保持不变 ...  
  "uni_modules": {  
    "uni-ext-api": {  
      "uni": {  
        "exitCurrentApp": "exitCurrentApp"   
      }  
    }  
  }  
}

三、 接口定义:确保类型安全(interface.uts)

为了在开发时获得更好的代码提示和类型检查,我们需要在 UTS 接口文件中定义新方法的签名。

打开 uni_modules/ohos-exit-helper/interface.uts 文件,扩展 Uni 接口,并声明我们的同步方法 exitCurrentApp()

// 文件路径:uni_modules/ohos-exit-helper/interface.uts  

// ... (省略文件开头其他类型定义) ...  

/**  
 * 扩展全局 uni 接口,添加鸿蒙原生方法  
 */  
interface Uni {  
  /**  
   * 【鸿蒙专属】安全退出当前应用。  
   * * @example  
   * ```typescript  
   * uni.exitCurrentApp()  
   * ```  
   * @remark  
   * - 该接口需同步调用  
   * @uniPlatform { "harmony": { "osVer": "3.0" } }  
   */  
  exitCurrentApp(): void, // 【关键重构】方法签名定义  
}

四、 鸿蒙原生实现:UTS 代码编写(index.uts)

这是实现退出功能的核心步骤。我们需要在插件目录下创建鸿蒙平台专用的实现文件,并利用鸿蒙的 Ability Context 来执行退出操作。

1. 确认路径: 确保你在插件目录下创建了 app-harmony 文件夹和 index.uts 文件:
uni_modules/ohos-exit-helper/app-harmony/index.uts

2. 编写代码: 写入以下代码。我们引入了 @ohos.app.ability.common 模块,并使用 context.terminateSelf() 这一原生方法来实现退出。


// 文件路径:uni_modules/ohos-exit-helper/app-harmony/index.uts  

// 引入鸿蒙 Ability 相关的公共模块  
import AbilityCommon from '@ohos.app.ability.common';   
import hiLog from '@ohos.hilog'; // 引入日志模块,便于调试  

/**  
 * 导出【鸿蒙实现】退出当前应用的功能  
 * * @returns {void}  
 */  
export function exitCurrentApp(): void { // 【关键重构】导出函数名  
  // 1. 获取当前 UIAbility 的上下文对象  
  // getContext() 是 uni-app UTS 提供的全局函数,用于获取原生上下文  
  const appAbilityContext = getContext() as AbilityCommon.UIAbilityContext;  

  // 2. 调用 terminateSelf() 方法来终止 Ability  
  appAbilityContext.terminateSelf();  

  // 3. 打印日志(重构日志变量名和内容)  
  const exitLogTag: string = "APP_CONTROL_LOG";  
  const statusMessage: string = "Application is shutting down gracefully via UTS plugin.";  

  hiLog.info(0x0001, exitLogTag, statusMessage);  
}

五、 在 uni-app 页面中调用插件

至此,我们的鸿蒙退出插件已经完成。最后一步,是在你的 uni-app 页面中引入并使用它。

你可以在任何 .vue.uvue`` 文件的<script lang="uts">` 块中调用此功能。

<template>  
  <view class="page-container">  
    <image class="app-logo" src="/static/logo.png"></image>  
    <text class="instruction-text">点击下方按钮,使用 UTS 实现鸿蒙应用退出。</text>  
    <view class="button-area">  
      <button class="exit-button" @click="handleAppExit">安全退出应用</button>  
    </view>  
  </view>  
</template>  

<script lang="uts">  

  import { exitCurrentApp } from "@/uni_modules/ohos-exit-helper"   

  export default {  
    data() {  
      return {  
        pageTitle: 'UTS退出功能演示',   
      }  
    },  
    methods: {  
      /**  
       * 处理点击退出按钮的逻辑方法  
       */  
      handleAppExit() { //   
        console.log("准备调用鸿蒙原生退出功能...");  
        // 调用我们插件中导出的退出函数  
        exitCurrentApp();   
      }  
    }  
  }  
</script>  

<style>  
  .page-container {  
    display: flex;  
    flex-direction: column;  
    align-items: center;  
    justify-content: center;  
    padding-top: 150px;  
    background-color: #f7f7f7;  
  }  
  .app-logo {  
    height: 180rpx;  
    width: 180rpx;  
    margin-bottom: 60rpx;  
  }  
  .instruction-text {  
    font-size: 34rpx;  
    color: #333333;  
    margin-bottom: 40rpx;  
  }  
  .exit-button {  
    width: 70%;  
    height: 90rpx;  
    line-height: 90rpx;  
    background-color: #007aff; /* 鸿蒙常用主色调 */  
    color: white;  
    font-size: 36rpx;  
    border-radius: 12rpx;  
    border: none;  
  }  
</style>

六、 运行与测试

现在,你可以运行你的 uni-app 项目到鸿蒙模拟器或真机上。

点击页面上的“安全退出应用”按钮,如果应用顺利关闭,那么恭喜你,你的第一个 UTS 鸿蒙插件就开发成功了!

通过这个示例,我们不仅学会了如何实现退出功能,更掌握了 uni-app 中 UTS 插件的基本开发流程,这是你深度学习鸿蒙原生能力的关键一步!

继续阅读 »

随着 uni-app 对鸿蒙开发的支持日益完善,我们现在可以轻松地使用一套代码同时发布到多个平台。不过,在开发鸿蒙(HarmonyOS)应用时,有些平台特有的功能需要我们通过 UTS 插件来调用原生能力。

本文将手把手教你如何创建一个 UTS 插件,并利用鸿蒙系统提供的原生接口,为你的 uni-app 应用添加一个“一键安全退出”的功能。

一、 UTS 插件的创建与命名

首先,我们需要在 uni-app 项目中创建一个专用于封装鸿蒙原生 API 的 UTS 插件。

  1. 创建插件: 在 HBuilderX 中,找到你的项目目录下的 uni_modules 文件夹。
  2. 右键点击 uni_modules -> 新建插件
  3. 命名插件: 为了与退出应用功能相关联,我们将插件命名为 ohos-exit-helper
  4. 选择类型: 选择插件类型为 uts

创建完成后,你的项目结构会多出一个 uni_modules/ohos-exit-helper 目录。

二、 插件配置:在 uni 对象上注册新方法

接下来,我们需要修改插件的配置文件,告诉 uni-app 框架:我们要在全局的 uni 对象上注册一个名为 exitCurrentApp 的新方法。

打开 uni_modules/ohos-exit-helper/package.json 文件,找到 uni_modules 字段,并添加如下配置:

// 文件路径:uni_modules/ohos-exit-helper/package.json  

{  
  // ... 其他配置项保持不变 ...  
  "uni_modules": {  
    "uni-ext-api": {  
      "uni": {  
        "exitCurrentApp": "exitCurrentApp"   
      }  
    }  
  }  
}

三、 接口定义:确保类型安全(interface.uts)

为了在开发时获得更好的代码提示和类型检查,我们需要在 UTS 接口文件中定义新方法的签名。

打开 uni_modules/ohos-exit-helper/interface.uts 文件,扩展 Uni 接口,并声明我们的同步方法 exitCurrentApp()

// 文件路径:uni_modules/ohos-exit-helper/interface.uts  

// ... (省略文件开头其他类型定义) ...  

/**  
 * 扩展全局 uni 接口,添加鸿蒙原生方法  
 */  
interface Uni {  
  /**  
   * 【鸿蒙专属】安全退出当前应用。  
   * * @example  
   * ```typescript  
   * uni.exitCurrentApp()  
   * ```  
   * @remark  
   * - 该接口需同步调用  
   * @uniPlatform { "harmony": { "osVer": "3.0" } }  
   */  
  exitCurrentApp(): void, // 【关键重构】方法签名定义  
}

四、 鸿蒙原生实现:UTS 代码编写(index.uts)

这是实现退出功能的核心步骤。我们需要在插件目录下创建鸿蒙平台专用的实现文件,并利用鸿蒙的 Ability Context 来执行退出操作。

1. 确认路径: 确保你在插件目录下创建了 app-harmony 文件夹和 index.uts 文件:
uni_modules/ohos-exit-helper/app-harmony/index.uts

2. 编写代码: 写入以下代码。我们引入了 @ohos.app.ability.common 模块,并使用 context.terminateSelf() 这一原生方法来实现退出。


// 文件路径:uni_modules/ohos-exit-helper/app-harmony/index.uts  

// 引入鸿蒙 Ability 相关的公共模块  
import AbilityCommon from '@ohos.app.ability.common';   
import hiLog from '@ohos.hilog'; // 引入日志模块,便于调试  

/**  
 * 导出【鸿蒙实现】退出当前应用的功能  
 * * @returns {void}  
 */  
export function exitCurrentApp(): void { // 【关键重构】导出函数名  
  // 1. 获取当前 UIAbility 的上下文对象  
  // getContext() 是 uni-app UTS 提供的全局函数,用于获取原生上下文  
  const appAbilityContext = getContext() as AbilityCommon.UIAbilityContext;  

  // 2. 调用 terminateSelf() 方法来终止 Ability  
  appAbilityContext.terminateSelf();  

  // 3. 打印日志(重构日志变量名和内容)  
  const exitLogTag: string = "APP_CONTROL_LOG";  
  const statusMessage: string = "Application is shutting down gracefully via UTS plugin.";  

  hiLog.info(0x0001, exitLogTag, statusMessage);  
}

五、 在 uni-app 页面中调用插件

至此,我们的鸿蒙退出插件已经完成。最后一步,是在你的 uni-app 页面中引入并使用它。

你可以在任何 .vue.uvue`` 文件的<script lang="uts">` 块中调用此功能。

<template>  
  <view class="page-container">  
    <image class="app-logo" src="/static/logo.png"></image>  
    <text class="instruction-text">点击下方按钮,使用 UTS 实现鸿蒙应用退出。</text>  
    <view class="button-area">  
      <button class="exit-button" @click="handleAppExit">安全退出应用</button>  
    </view>  
  </view>  
</template>  

<script lang="uts">  

  import { exitCurrentApp } from "@/uni_modules/ohos-exit-helper"   

  export default {  
    data() {  
      return {  
        pageTitle: 'UTS退出功能演示',   
      }  
    },  
    methods: {  
      /**  
       * 处理点击退出按钮的逻辑方法  
       */  
      handleAppExit() { //   
        console.log("准备调用鸿蒙原生退出功能...");  
        // 调用我们插件中导出的退出函数  
        exitCurrentApp();   
      }  
    }  
  }  
</script>  

<style>  
  .page-container {  
    display: flex;  
    flex-direction: column;  
    align-items: center;  
    justify-content: center;  
    padding-top: 150px;  
    background-color: #f7f7f7;  
  }  
  .app-logo {  
    height: 180rpx;  
    width: 180rpx;  
    margin-bottom: 60rpx;  
  }  
  .instruction-text {  
    font-size: 34rpx;  
    color: #333333;  
    margin-bottom: 40rpx;  
  }  
  .exit-button {  
    width: 70%;  
    height: 90rpx;  
    line-height: 90rpx;  
    background-color: #007aff; /* 鸿蒙常用主色调 */  
    color: white;  
    font-size: 36rpx;  
    border-radius: 12rpx;  
    border: none;  
  }  
</style>

六、 运行与测试

现在,你可以运行你的 uni-app 项目到鸿蒙模拟器或真机上。

点击页面上的“安全退出应用”按钮,如果应用顺利关闭,那么恭喜你,你的第一个 UTS 鸿蒙插件就开发成功了!

通过这个示例,我们不仅学会了如何实现退出功能,更掌握了 uni-app 中 UTS 插件的基本开发流程,这是你深度学习鸿蒙原生能力的关键一步!

收起阅读 »

用uni-app搞了个足球战术板,踩了不少canvas坑,分享一下经验

鸿蒙征文

前言

最近开发了一款【苏超排名助手】,里面有一个功能页:足球战术板,主要就是让用户能在球场上画箭头、线条、曲线啥的,方便教练布置战术。本来以为挺简单的,结果各种坑,尤其是要兼容鸿蒙,差点没给我整崩溃。不过最后还是搞定了,记录一下,给后面的兄弟们少踩点坑。

需求是啥

说白了就是这几个功能:

  • 在球场上画箭头(传球路线)
  • 画直线和曲线(跑位路线)
  • 橡皮擦,画错了能擦掉
  • 能保存成图片分享
  • 还得支持鸿蒙(这个最坑)

Canvas初始化 - 第一个大坑

鸿蒙和其他平台的API不一样

uni-app有两种Canvas API:

  • 旧的:uni.createCanvasContext('canvasId')
  • 新的:Canvas 2D(type="2d")

鸿蒙现在只支持旧API,所以得这样写:

<!-- #ifdef APP-HARMONY -->  
<canvas  
  canvas-id="tacticsCanvas"  
  id="tacticsCanvas"  
  class="canvas-board"  
  @touchstart="handleCanvasTouchStart"  
  @touchmove="handleCanvasTouchMove"  
  @touchend="handleCanvasTouchEnd"  
  disable-scroll="true"  
></canvas>  
<!-- #endif -->  

<!-- #ifndef APP-HARMONY -->  
<canvas  
  type="2d"  
  id="tacticsCanvas"  
  canvas-id="tacticsCanvas"  
  class="canvas-board"  
  @touchstart="handleCanvasTouchStart"  
  @touchmove.prevent="handleCanvasTouchMove"  
  @touchend="handleCanvasTouchEnd"  
></canvas>  
<!-- #endif -->

注意几个细节:

  1. 鸿蒙不用写type="2d"
  2. 鸿蒙用disable-scroll="true",其他平台用@touchmove.prevent
  3. 两个平台都得有canvas-idid

初始化时机很关键

不能在onMounted里直接初始化,得等页面真正渲染完了再搞。我试了好几次,最后发现延迟个200ms比较靠谱:

onMounted(() => {  
  // #ifdef APP-HARMONY  
  setTimeout(() => {  
    const systemInfo = uni.getSystemInfoSync();  
    canvasWidth.value = systemInfo.windowWidth;  
    canvasHeight.value = systemInfo.windowWidth * 1.25;  

    ctx.value = uni.createCanvasContext("tacticsCanvas");  
    ctx.value.isCanvas2d = false; // 标记是旧API  

    // 再延迟获取真实边界  
    setTimeout(() => {  
      const query = uni.createSelectorQuery();  
      query.select(".canvas-board").boundingClientRect((rect) => {  
        if (rect) {  
          boardRect.value = rect;  
          canvasReady.value = true; // 就绪标记  
        }  
      }).exec();  
    }, 100);  
  }, 200);  
  // #endif  
});

为啥要搞个isCanvas2d标记?因为后面很多API调用方式不一样,得区分开。

触摸绘制 - 坐标转换是重点

坐标系问题

这个坑我踩了好久。触摸事件返回的坐标,在不同平台上格式不一样:

  • 鸿蒙:touch.x / touch.y(可能是相对坐标)
  • 其他平台:touch.clientX / touch.clientY(需要减去canvas的偏移)

我写了个通用函数处理:

const getCanvasCoords = (touch) => {  
  let x, y;  

  // #ifdef APP-HARMONY  
  if (touch.x !== undefined && touch.y !== undefined) {  
    const rawX = touch.x;  
    const rawY = touch.y;  

    // 如果在合理范围内,直接用  
    if (rawX >= 0 && rawX <= canvasWidth.value &&   
        rawY >= 0 && rawY <= canvasHeight.value) {  
      x = rawX;  
      y = rawY;  
    }  
    // 超出范围就减去偏移  
    else if (boardRect.value && rawX > canvasWidth.value) {  
      x = rawX - boardRect.value.left;  
      y = rawY - boardRect.value.top;  
    }  
    else {  
      x = rawX;  
      y = rawY;  
    }  
  }  
  // #endif  

  // #ifndef APP-HARMONY  
  if (touch.clientX !== undefined && touch.clientY !== undefined && boardRect.value) {  
    x = touch.clientX - boardRect.value.left;  
    y = touch.clientY - boardRect.value.top;  
  }  
  // #endif  

  // 限制在画布范围内  
  x = Math.max(0, Math.min(x, canvasWidth.value));  
  y = Math.max(0, Math.min(y, canvasHeight.value));  

  return { x, y };  
};

绘制流程

整个绘制流程是这样的:

  1. touchstart:记录起点,初始化当前绘制对象

    const handleCanvasTouchStart = (e) => {  
    if (!canvasReady.value) {  
    uni.showToast({ title: "画布初始化中,请稍候", icon: "none" });  
    return;  
    }  
    
    const touch = e.touches[0];  
    const coords = getCanvasCoords(touch);  
    
    isDrawing.value = true;  
    currentDrawing.value = {  
    type: currentTool.value, // arrow/line/curve  
    startX: coords.x,  
    startY: coords.y,  
    endX: coords.x,  
    endY: coords.y,  
    points: [{ x: coords.x, y: coords.y }], // 曲线用  
    color: currentTool.value === 'arrow' ? '#ff6b35' : '#1a3b6e'  
    };  
    };
  2. touchmove:更新终点或添加曲线点

    const handleCanvasTouchMove = (e) => {  
    if (!isDrawing.value || !currentDrawing.value) return;  
    
    const touch = e.touches[0];  
    const coords = getCanvasCoords(touch);  
    
    if (currentTool.value === 'curve') {  
    // 曲线:不断添加点  
    currentDrawing.value.points.push({ x: coords.x, y: coords.y });  
    } else {  
    // 箭头/直线:更新终点  
    currentDrawing.value.endX = coords.x;  
    currentDrawing.value.endY = coords.y;  
    }  
    
    redrawCanvas(); // 实时重绘  
    };
  3. touchend:保存到数组

    const handleCanvasTouchEnd = () => {  
    if (isDrawing.value && currentDrawing.value) {  
    drawings.value.push({ ...currentDrawing.value });  
    isDrawing.value = false;  
    currentDrawing.value = null;  
    redrawCanvas();  
    }  
    };

绘制各种图形

兼容两种API的方法

旧API和新API的方法名不一样,得封装一下:

const drawShape = (shape, context = null) => {  
  const drawCtx = context || ctx.value;  
  if (!drawCtx) return;  

  // 兼容函数  
  const setStrokeStyle = (color) => {  
    if (drawCtx.isCanvas2d) {  
      drawCtx.strokeStyle = color;  
    } else {  
      drawCtx.setStrokeStyle(color); // 旧API  
    }  
  };  

  const setLineWidth = (width) => {  
    if (drawCtx.isCanvas2d) {  
      drawCtx.lineWidth = width;  
    } else {  
      drawCtx.setLineWidth(width);  
    }  
  };  

  const setLineCap = (cap) => {  
    if (drawCtx.isCanvas2d) {  
      drawCtx.lineCap = cap;  
    } else {  
      drawCtx.setLineCap(cap);  
    }  
  };  

  // ... 其他方法类似  
};

绘制箭头

箭头是最常用的,分两部分:线段 + 箭头头部

if (shape.type === 'arrow') {  
  const endX = shape.endX || shape.startX;  
  const endY = shape.endY || shape.startY;  

  // 1. 画线  
  drawCtx.beginPath();  
  drawCtx.moveTo(shape.startX, shape.startY);  
  drawCtx.lineTo(endX, endY);  
  drawCtx.stroke();  

  // 2. 画箭头头部(两条边)  
  const angle = Math.atan2(endY - shape.startY, endX - shape.startX);  
  const arrowLength = 25;  

  setLineWidth(5);  
  drawCtx.beginPath();  
  drawCtx.moveTo(endX, endY);  
  // 左边  
  drawCtx.lineTo(  
    endX - arrowLength * Math.cos(angle - Math.PI / 6),  
    endY - arrowLength * Math.sin(angle - Math.PI / 6)  
  );  
  drawCtx.moveTo(endX, endY);  
  // 右边  
  drawCtx.lineTo(  
    endX - arrowLength * Math.cos(angle + Math.PI / 6),  
    endY - arrowLength * Math.sin(angle + Math.PI / 6)  
  );  
  drawCtx.stroke();  
}

箭头的原理:

  1. 先算出线的角度:Math.atan2(dy, dx)
  2. 箭头两边各偏离30度(Math.PI / 6)
  3. 用三角函数算出两条边的终点坐标

绘制曲线(虚线)

曲线用来表示跑位,所以用虚线:

if (shape.type === 'curve' && shape.points) {  
  setLineDash([10, 5]); // 虚线:10px实线,5px空白  
  drawCtx.beginPath();  
  drawCtx.moveTo(shape.points[0].x, shape.points[0].y);  
  for (let i = 1; i < shape.points.length; i++) {  
    drawCtx.lineTo(shape.points[i].x, shape.points[i].y);  
  }  
  drawCtx.stroke();  
  setLineDash([]); // 恢复实线  
}

橡皮擦功能 - 点到线段距离算法

橡皮擦要判断点击的位置是不是靠近某条线,这个用到点到线段距离公式:

const pointToLineDistance = (px, py, x1, y1, x2, y2) => {  
  const A = px - x1;  
  const B = py - y1;  
  const C = x2 - x1;  
  const D = y2 - y1;  

  const dot = A * C + B * D;  
  const lenSq = C * C + D * D;  
  let param = -1;  

  if (lenSq !== 0) param = dot / lenSq;  

  let xx, yy;  

  if (param < 0) {  
    // 点在线段外侧,靠近起点  
    xx = x1;  
    yy = y1;  
  } else if (param > 1) {  
    // 点在线段外侧,靠近终点  
    xx = x2;  
    yy = y2;  
  } else {  
    // 点在线段范围内  
    xx = x1 + param * C;  
    yy = y1 + param * D;  
  }  

  const dx = px - xx;  
  const dy = py - yy;  
  return Math.sqrt(dx * dx + dy * dy);  
};

这个算法的核心思路:

  1. 把点投影到直线上
  2. 判断投影点是不是在线段范围内
  3. 算出点到投影点的距离

然后在touchstart判断:

if (currentTool.value === 'eraser') {  
  const clickX = coords.x;  
  const clickY = coords.y;  
  const eraseRadius = 30; // 橡皮擦范围  

  for (let i = drawings.value.length - 1; i >= 0; i--) {  
    const drawing = drawings.value[i];  
    let isNear = false;  

    if (drawing.type === 'line' || drawing.type === 'arrow') {  
      const dist = pointToLineDistance(  
        clickX, clickY,  
        drawing.startX, drawing.startY,  
        drawing.endX, drawing.endY  
      );  
      isNear = dist < eraseRadius;  
    } else if (drawing.type === 'curve') {  
      // 曲线:检查是否靠近任意点  
      for (let point of drawing.points) {  
        const dist = Math.sqrt(  
          Math.pow(clickX - point.x, 2) + Math.pow(clickY - point.y, 2)  
        );  
        if (dist < eraseRadius) {  
          isNear = true;  
          break;  
        }  
      }  
    }  

    if (isNear) {  
      drawings.value.splice(i, 1);  
      redrawCanvas();  
      break;  
    }  
  }  
}

重绘画布 - 性能优化

每次操作都要重绘整个画布,顺序很重要:

const redrawCanvas = () => {  
  if (!ctx.value) return;  

  // 1. 清空画布(但不渲染)  
  ctx.value.clearRect(0, 0, canvasWidth.value, canvasHeight.value);  

  // 2. 绘制所有已保存的图形  
  drawings.value.forEach((drawing) => {  
    drawShape(drawing);  
  });  

  // 3. 绘制当前正在画的(实时反馈)  
  if (currentDrawing.value) {  
    drawShape(currentDrawing.value);  
  }  

  // 4. 旧API必须调用draw()才能渲染到屏幕  
  if (!ctx.value.isCanvas2d) {  
    ctx.value.draw(false); // false表示不清空之前的内容  
  }  
};

注意:

  • 新API(Canvas 2D):每次绘制都是实时的
  • 旧API:必须调用draw()才会显示
  • 鸿蒙用的是旧API,别忘了draw()
  • draw(false)的false很关键,true会清空画布

导出图片 - 生成分享海报

这个功能挺实用的,步骤是这样的:

  1. 先截取战术板:把球场、球员、战术线都画到临时canvas上
  2. 再画海报:在另一个canvas上画标题、战术板图片、底部信息
  3. 导出为图片:用uni.canvasToTempFilePath

绘制战术板完整内容

const captureBoard = () => {  
  return new Promise((resolve, reject) => {  
    const tempCtx = uni.createCanvasContext("tacticsCanvas");  
    tempCtx.isCanvas2d = false;  

    // 1. 画背景(球场)  
    tempCtx.setFillStyle("#2d8659");  
    tempCtx.fillRect(0, 0, boardWidth, boardHeight);  

    // 2. 画场地线(中线、禁区等)  
    tempCtx.setStrokeStyle("rgba(255, 255, 255, 0.6)");  
    tempCtx.setLineWidth(2);  
    tempCtx.beginPath();  
    tempCtx.moveTo(0, boardHeight * 0.5);  
    tempCtx.lineTo(boardWidth, boardHeight * 0.5);  
    tempCtx.stroke();  

    // 中圈  
    const centerCircleRadius = (100 / 750) * boardWidth;  
    tempCtx.beginPath();  
    tempCtx.arc(boardWidth * 0.5, boardHeight * 0.5, centerCircleRadius, 0, 2 * Math.PI);  
    tempCtx.stroke();  

    // ... 更多场地线  

    // 3. 画战术线路  
    drawings.value.forEach((drawing) => {  
      drawShape(drawing, tempCtx);  
    });  

    // 4. 画球员  
    playersOnField.value.forEach((player) => {  
      const markerRadius = (60 / 2) * (screenWidth / 750);  
      const centerX = player.x + markerRadius;  
      const centerY = player.y + markerRadius;  

      // 圆圈  
      tempCtx.setFillStyle(player.team === 'home' ? '#1a3b6e' : '#ff6b35');  
      tempCtx.beginPath();  
      tempCtx.arc(centerX, centerY, markerRadius, 0, 2 * Math.PI);  
      tempCtx.fill();  

      // 号码  
      tempCtx.setFillStyle('#ffffff');  
      tempCtx.setFontSize(markerRadius * 0.8);  
      tempCtx.setTextAlign('center');  
      tempCtx.setTextBaseline('middle');  
      tempCtx.fillText(player.number, centerX, centerY);  
    });  

    // 5. 导出  
    tempCtx.draw(false, () => {  
      setTimeout(() => {  
        uni.canvasToTempFilePath({  
          canvasId: "tacticsCanvas",  
          success: (res) => {  
            resolve(res.tempFilePath);  
          },  
          fail: reject  
        });  
      }, 500); // 等渲染完成  
    });  
  });  
};

绘制海报

const drawSharePoster = (boardImagePath) => {  
  return new Promise((resolve, reject) => {  
    const pWidth = 375;  
    const pHeight = 550;  
    const pCtx = uni.createCanvasContext("sharePosterCanvas");  

    // 1. 背景渐变  
    const gradient = pCtx.createLinearGradient(0, 0, 0, pHeight);  
    gradient.addColorStop(0, "#1a3b6e");  
    gradient.addColorStop(1, "#2c5282");  
    pCtx.setFillStyle(gradient);  
    pCtx.fillRect(0, 0, pWidth, pHeight);  

    // 2. 标题  
    pCtx.setFillStyle("#ffffff");  
    pCtx.setFontSize(24);  
    pCtx.setTextAlign("center");  
    pCtx.fillText("⚽ 足球战术板", pWidth / 2, 40);  

    // 3. 副标题  
    pCtx.setFillStyle("rgba(255, 255, 255, 0.8)");  
    pCtx.setFontSize(14);  
    pCtx.fillText("Football Tactics Board", pWidth / 2, 65);  

    // 4. 战术板图片(重点!)  
    const margin = 20;  
    const maxBoardWidth = pWidth - margin * 2;  
    const boardAspect = boardRect.value.width / boardRect.value.height;  
    let drawWidth = maxBoardWidth;  
    let drawHeight = drawWidth / boardAspect;  

    const boardX = (pWidth - drawWidth) / 2;  
    const boardY = 90;  

    // 白色卡片背景  
    pCtx.setFillStyle("#ffffff");  
    pCtx.fillRect(boardX - 8, boardY - 8, drawWidth + 16, drawHeight + 16);  

    // 画战术板图片  
    pCtx.drawImage(boardImagePath, boardX, boardY, drawWidth, drawHeight);  

    // 5. 底部信息  
    const footerY = pHeight - 50;  
    pCtx.setFillStyle("rgba(255, 255, 255, 0.9)");  
    pCtx.setFontSize(16);  
    pCtx.fillText("苏超排名助手", pWidth / 2, footerY);  

    const now = new Date();  
    const timeStr = `${now.getFullYear()}-${now.getMonth()+1}-${now.getDate()}`;  
    pCtx.setFillStyle("rgba(255, 255, 255, 0.5)");  
    pCtx.setFontSize(10);  
    pCtx.fillText(`生成时间: ${timeStr}`, pWidth / 2, footerY + 25);  

    // 6. 导出高清图(2倍分辨率)  
    pCtx.draw(false, () => {  
      setTimeout(() => {  
        uni.canvasToTempFilePath({  
          canvasId: "sharePosterCanvas",  
          width: pWidth,  
          height: pHeight,  
          destWidth: pWidth * 2,  // 2倍分辨率  
          destHeight: pHeight * 2,  
          fileType: "png",  
          quality: 1,  
          success: (res) => resolve(res.tempFilePath),  
          fail: reject  
        });  
      }, 800);  
    });  
  });  
};

注意destWidthdestHeight,设置成2倍能提高导出图片的清晰度。

踩过的坑总结

1. 初始化时机问题

症状:获取不到canvas尺寸,或者坐标转换不准

原因:页面还没渲染完就初始化了

解决:延迟200ms,再用createSelectorQuery获取真实位置

setTimeout(() => {  
  const query = uni.createSelectorQuery();  
  query.select(".canvas-board").boundingClientRect((rect) => {  
    boardRect.value = rect;  
    canvasReady.value = true;  
  }).exec();  
}, 200);

2. 坐标转换不准确

症状:画的位置跟手指点的位置对不上

原因:没考虑canvas的偏移量(padding、margin等)

解决:必须用boundingClientRect获取真实位置,然后减去偏移

x = touch.clientX - boardRect.value.left;  
y = touch.clientY - boardRect.value.top;

3. 鸿蒙draw()必须调用

症状:画了但是屏幕上看不到

原因:旧API必须调用draw()才会渲染

解决:每次绘制完都调用

if (!ctx.value.isCanvas2d) {  
  ctx.value.draw(false);  
}

4. 导出图片时机问题

症状:导出的图片是空白的或者不完整

原因draw()是异步的,还没渲染完就导出了

解决:在draw()的回调里,再延迟500ms

ctx.draw(false, () => {  
  setTimeout(() => {  
    uni.canvasToTempFilePath({...});  
  }, 500);  
});

5. 橡皮擦范围不好控制

症状:点了删不掉,或者不小心删了别的线

原因:距离阈值设置不合理

解决:30-40px比较合适,太小不好点,太大容易误删

const eraseRadius = 30; // 橡皮擦范围  
if (dist < eraseRadius) {  
  // 删除  
}

6. touchmove和页面滚动冲突

症状:画线的时候页面跟着滚动

原因:touchmove事件冒泡到了页面

解决:加.stop.prevent修饰符

<canvas @touchmove.stop.prevent="handleCanvasTouchMove"></canvas>

但是鸿蒙不支持.prevent,要用disable-scroll="true"

<!-- #ifdef APP-HARMONY -->  
<canvas disable-scroll="true"></canvas>  
<!-- #endif -->

7. 重绘性能问题

症状:线条多了之后很卡

原因:每次touchmove都要重绘所有线条

解决

  • 限制绘制对象数量(最多50个)
  • touchmove节流(每10ms更新一次)
  • 复杂背景用离屏canvas缓存
let lastTime = 0;  
const handleCanvasTouchMove = (e) => {  
  const now = Date.now();  
  if (now - lastTime < 10) return; // 节流  
  lastTime = now;  
  // ...  
};

8. 图片清晰度问题

症状:导出的图片很模糊

原因:没设置destWidthdestHeight

解决:设置成实际尺寸的2倍

uni.canvasToTempFilePath({  
  canvasId: "myCanvas",  
  destWidth: width * 2,  
  destHeight: height * 2,  
  fileType: "png",  
  quality: 1  
});

性能优化建议

1. 节流touchmove

绘制曲线时,不用每次touchmove都添加点:

let lastTime = 0;  
const handleCanvasTouchMove = (e) => {  
  const now = Date.now();  
  if (now - lastTime < 16) return; // 约60fps  
  lastTime = now;  

  const coords = getCanvasCoords(e.touches[0]);  
  currentDrawing.value.points.push(coords);  
  redrawCanvas();  
};

2. 限制绘制数量

太多绘制对象会导致重绘变慢:

const MAX_DRAWINGS = 50;  

const handleCanvasTouchEnd = () => {  
  if (isDrawing.value && currentDrawing.value) {  
    drawings.value.push({ ...currentDrawing.value });  

    // 限制数量  
    if (drawings.value.length > MAX_DRAWINGS) {  
      drawings.value.shift(); // 删除最早的  
    }  

    redrawCanvas();  
  }  
};

3. 离屏canvas缓存背景

球场背景每次都画很费性能,可以缓存起来:

let backgroundCache = null;  

const cacheBackground = () => {  
  const offscreenCanvas = uni.createOffscreenCanvas({  
    type: '2d',  
    width: canvasWidth.value,  
    height: canvasHeight.value  
  });  

  const offCtx = offscreenCanvas.getContext('2d');  

  // 画球场背景  
  offCtx.fillStyle = '#2d8659';  
  offCtx.fillRect(0, 0, canvasWidth.value, canvasHeight.value);  
  // ... 画其他场地线  

  backgroundCache = offscreenCanvas;  
};  

const redrawCanvas = () => {  
  if (!ctx.value) return;  

  ctx.value.clearRect(0, 0, canvasWidth.value, canvasHeight.value);  

  // 用缓存的背景  
  if (backgroundCache) {  
    ctx.value.drawImage(backgroundCache, 0, 0);  
  }  

  // 画战术线  
  drawings.value.forEach(drawing => drawShape(drawing));  

  if (!ctx.value.isCanvas2d) {  
    ctx.value.draw(false);  
  }  
};

4. 按需重绘

如果只是改变某个元素,可以不重绘整个画布(但Canvas API不支持局部重绘,这个比较难实现)。

替代方案:把不同层分开

  • 背景层:静态的球场(很少变化)
  • 战术层:箭头、线条(经常变化)
  • 球员层:用普通DOM而不是Canvas
<!-- 背景canvas -->  
<canvas id="bgCanvas" class="bg-layer"></canvas>  

<!-- 战术canvas -->  
<canvas id="tacticsCanvas" class="tactics-layer"></canvas>  

<!-- 球员用普通DOM -->  
<view class="players-layer">  
  <view v-for="player in players" :key="player.id"   
        :style="{left: player.x, top: player.y}">  
    {{ player.number }}  
  </view>  
</view>

这样球员拖动就不需要重绘canvas了。

实用技巧

1. 调试坐标

在开发时可以实时显示坐标,方便调试:

const handleCanvasTouchMove = (e) => {  
  const coords = getCanvasCoords(e.touches[0]);  
  console.log(`x: ${coords.x}, y: ${coords.y}`);  
  // 或者显示在页面上  
  debugInfo.value = `x: ${coords.x}, y: ${coords.y}`;  
};

2. 撤销功能

保存历史记录数组:

const history = ref([]);  
const historyIndex = ref(-1);  

const saveHistory = () => {  
  // 删除当前索引之后的历史  
  history.value = history.value.slice(0, historyIndex.value + 1);  
  // 添加新状态  
  history.value.push(JSON.parse(JSON.stringify(drawings.value)));  
  historyIndex.value++;  

  // 限制历史记录数量  
  if (history.value.length > 20) {  
    history.value.shift();  
    historyIndex.value--;  
  }  
};  

const undo = () => {  
  if (historyIndex.value > 0) {  
    historyIndex.value--;  
    drawings.value = JSON.parse(JSON.stringify(history.value[historyIndex.value]));  
    redrawCanvas();  
  }  
};  

const redo = () => {  
  if (historyIndex.value < history.value.length - 1) {  
    historyIndex.value++;  
    drawings.value = JSON.parse(JSON.stringify(history.value[historyIndex.value]));  
    redrawCanvas();  
  }  
};

3. 颜色选择器

让用户自定义线条颜色:

const colors = ['#ff6b35', '#1a3b6e', '#4caf50', '#f44336', '#9e9e9e'];  
const currentColor = ref('#ff6b35');  

const handleCanvasTouchStart = (e) => {  
  // ...  
  currentDrawing.value = {  
    type: currentTool.value,  
    startX: coords.x,  
    startY: coords.y,  
    color: currentColor.value // 使用选中的颜色  
  };  
};

4. 线条粗细调整

const lineWidths = [2, 4, 6, 8];  
const currentWidth = ref(4);  

const drawShape = (shape) => {  
  // ...  
  const lineWidth = shape.width || 4;  
  setLineWidth(lineWidth);  
  // ...  
};

最后

整个功能做下来,最大的感受就是:Canvas在uni-app编译到鸿蒙还是有不少坑的。不过搞定之后还挺有成就感的。

开发建议

  1. 先在H5上调试,等逻辑都对了再适配鸿蒙
  2. 鸿蒙的真机调试很慢,多用console.loguni.showToast
  3. 坐标问题一定要用实际设备测试,模拟器不准
  4. 多保存代码,Canvas相关的代码很容易改崩

适用场景

  • 战术板(足球、篮球等)
  • 签名功能
  • 涂鸦板
  • 路径规划
  • 图片标注

代码在附件,有问题可以留言交流。希望能帮到要做类似功能的兄弟们!

继续阅读 »

前言

最近开发了一款【苏超排名助手】,里面有一个功能页:足球战术板,主要就是让用户能在球场上画箭头、线条、曲线啥的,方便教练布置战术。本来以为挺简单的,结果各种坑,尤其是要兼容鸿蒙,差点没给我整崩溃。不过最后还是搞定了,记录一下,给后面的兄弟们少踩点坑。

需求是啥

说白了就是这几个功能:

  • 在球场上画箭头(传球路线)
  • 画直线和曲线(跑位路线)
  • 橡皮擦,画错了能擦掉
  • 能保存成图片分享
  • 还得支持鸿蒙(这个最坑)

Canvas初始化 - 第一个大坑

鸿蒙和其他平台的API不一样

uni-app有两种Canvas API:

  • 旧的:uni.createCanvasContext('canvasId')
  • 新的:Canvas 2D(type="2d")

鸿蒙现在只支持旧API,所以得这样写:

<!-- #ifdef APP-HARMONY -->  
<canvas  
  canvas-id="tacticsCanvas"  
  id="tacticsCanvas"  
  class="canvas-board"  
  @touchstart="handleCanvasTouchStart"  
  @touchmove="handleCanvasTouchMove"  
  @touchend="handleCanvasTouchEnd"  
  disable-scroll="true"  
></canvas>  
<!-- #endif -->  

<!-- #ifndef APP-HARMONY -->  
<canvas  
  type="2d"  
  id="tacticsCanvas"  
  canvas-id="tacticsCanvas"  
  class="canvas-board"  
  @touchstart="handleCanvasTouchStart"  
  @touchmove.prevent="handleCanvasTouchMove"  
  @touchend="handleCanvasTouchEnd"  
></canvas>  
<!-- #endif -->

注意几个细节:

  1. 鸿蒙不用写type="2d"
  2. 鸿蒙用disable-scroll="true",其他平台用@touchmove.prevent
  3. 两个平台都得有canvas-idid

初始化时机很关键

不能在onMounted里直接初始化,得等页面真正渲染完了再搞。我试了好几次,最后发现延迟个200ms比较靠谱:

onMounted(() => {  
  // #ifdef APP-HARMONY  
  setTimeout(() => {  
    const systemInfo = uni.getSystemInfoSync();  
    canvasWidth.value = systemInfo.windowWidth;  
    canvasHeight.value = systemInfo.windowWidth * 1.25;  

    ctx.value = uni.createCanvasContext("tacticsCanvas");  
    ctx.value.isCanvas2d = false; // 标记是旧API  

    // 再延迟获取真实边界  
    setTimeout(() => {  
      const query = uni.createSelectorQuery();  
      query.select(".canvas-board").boundingClientRect((rect) => {  
        if (rect) {  
          boardRect.value = rect;  
          canvasReady.value = true; // 就绪标记  
        }  
      }).exec();  
    }, 100);  
  }, 200);  
  // #endif  
});

为啥要搞个isCanvas2d标记?因为后面很多API调用方式不一样,得区分开。

触摸绘制 - 坐标转换是重点

坐标系问题

这个坑我踩了好久。触摸事件返回的坐标,在不同平台上格式不一样:

  • 鸿蒙:touch.x / touch.y(可能是相对坐标)
  • 其他平台:touch.clientX / touch.clientY(需要减去canvas的偏移)

我写了个通用函数处理:

const getCanvasCoords = (touch) => {  
  let x, y;  

  // #ifdef APP-HARMONY  
  if (touch.x !== undefined && touch.y !== undefined) {  
    const rawX = touch.x;  
    const rawY = touch.y;  

    // 如果在合理范围内,直接用  
    if (rawX >= 0 && rawX <= canvasWidth.value &&   
        rawY >= 0 && rawY <= canvasHeight.value) {  
      x = rawX;  
      y = rawY;  
    }  
    // 超出范围就减去偏移  
    else if (boardRect.value && rawX > canvasWidth.value) {  
      x = rawX - boardRect.value.left;  
      y = rawY - boardRect.value.top;  
    }  
    else {  
      x = rawX;  
      y = rawY;  
    }  
  }  
  // #endif  

  // #ifndef APP-HARMONY  
  if (touch.clientX !== undefined && touch.clientY !== undefined && boardRect.value) {  
    x = touch.clientX - boardRect.value.left;  
    y = touch.clientY - boardRect.value.top;  
  }  
  // #endif  

  // 限制在画布范围内  
  x = Math.max(0, Math.min(x, canvasWidth.value));  
  y = Math.max(0, Math.min(y, canvasHeight.value));  

  return { x, y };  
};

绘制流程

整个绘制流程是这样的:

  1. touchstart:记录起点,初始化当前绘制对象

    const handleCanvasTouchStart = (e) => {  
    if (!canvasReady.value) {  
    uni.showToast({ title: "画布初始化中,请稍候", icon: "none" });  
    return;  
    }  
    
    const touch = e.touches[0];  
    const coords = getCanvasCoords(touch);  
    
    isDrawing.value = true;  
    currentDrawing.value = {  
    type: currentTool.value, // arrow/line/curve  
    startX: coords.x,  
    startY: coords.y,  
    endX: coords.x,  
    endY: coords.y,  
    points: [{ x: coords.x, y: coords.y }], // 曲线用  
    color: currentTool.value === 'arrow' ? '#ff6b35' : '#1a3b6e'  
    };  
    };
  2. touchmove:更新终点或添加曲线点

    const handleCanvasTouchMove = (e) => {  
    if (!isDrawing.value || !currentDrawing.value) return;  
    
    const touch = e.touches[0];  
    const coords = getCanvasCoords(touch);  
    
    if (currentTool.value === 'curve') {  
    // 曲线:不断添加点  
    currentDrawing.value.points.push({ x: coords.x, y: coords.y });  
    } else {  
    // 箭头/直线:更新终点  
    currentDrawing.value.endX = coords.x;  
    currentDrawing.value.endY = coords.y;  
    }  
    
    redrawCanvas(); // 实时重绘  
    };
  3. touchend:保存到数组

    const handleCanvasTouchEnd = () => {  
    if (isDrawing.value && currentDrawing.value) {  
    drawings.value.push({ ...currentDrawing.value });  
    isDrawing.value = false;  
    currentDrawing.value = null;  
    redrawCanvas();  
    }  
    };

绘制各种图形

兼容两种API的方法

旧API和新API的方法名不一样,得封装一下:

const drawShape = (shape, context = null) => {  
  const drawCtx = context || ctx.value;  
  if (!drawCtx) return;  

  // 兼容函数  
  const setStrokeStyle = (color) => {  
    if (drawCtx.isCanvas2d) {  
      drawCtx.strokeStyle = color;  
    } else {  
      drawCtx.setStrokeStyle(color); // 旧API  
    }  
  };  

  const setLineWidth = (width) => {  
    if (drawCtx.isCanvas2d) {  
      drawCtx.lineWidth = width;  
    } else {  
      drawCtx.setLineWidth(width);  
    }  
  };  

  const setLineCap = (cap) => {  
    if (drawCtx.isCanvas2d) {  
      drawCtx.lineCap = cap;  
    } else {  
      drawCtx.setLineCap(cap);  
    }  
  };  

  // ... 其他方法类似  
};

绘制箭头

箭头是最常用的,分两部分:线段 + 箭头头部

if (shape.type === 'arrow') {  
  const endX = shape.endX || shape.startX;  
  const endY = shape.endY || shape.startY;  

  // 1. 画线  
  drawCtx.beginPath();  
  drawCtx.moveTo(shape.startX, shape.startY);  
  drawCtx.lineTo(endX, endY);  
  drawCtx.stroke();  

  // 2. 画箭头头部(两条边)  
  const angle = Math.atan2(endY - shape.startY, endX - shape.startX);  
  const arrowLength = 25;  

  setLineWidth(5);  
  drawCtx.beginPath();  
  drawCtx.moveTo(endX, endY);  
  // 左边  
  drawCtx.lineTo(  
    endX - arrowLength * Math.cos(angle - Math.PI / 6),  
    endY - arrowLength * Math.sin(angle - Math.PI / 6)  
  );  
  drawCtx.moveTo(endX, endY);  
  // 右边  
  drawCtx.lineTo(  
    endX - arrowLength * Math.cos(angle + Math.PI / 6),  
    endY - arrowLength * Math.sin(angle + Math.PI / 6)  
  );  
  drawCtx.stroke();  
}

箭头的原理:

  1. 先算出线的角度:Math.atan2(dy, dx)
  2. 箭头两边各偏离30度(Math.PI / 6)
  3. 用三角函数算出两条边的终点坐标

绘制曲线(虚线)

曲线用来表示跑位,所以用虚线:

if (shape.type === 'curve' && shape.points) {  
  setLineDash([10, 5]); // 虚线:10px实线,5px空白  
  drawCtx.beginPath();  
  drawCtx.moveTo(shape.points[0].x, shape.points[0].y);  
  for (let i = 1; i < shape.points.length; i++) {  
    drawCtx.lineTo(shape.points[i].x, shape.points[i].y);  
  }  
  drawCtx.stroke();  
  setLineDash([]); // 恢复实线  
}

橡皮擦功能 - 点到线段距离算法

橡皮擦要判断点击的位置是不是靠近某条线,这个用到点到线段距离公式:

const pointToLineDistance = (px, py, x1, y1, x2, y2) => {  
  const A = px - x1;  
  const B = py - y1;  
  const C = x2 - x1;  
  const D = y2 - y1;  

  const dot = A * C + B * D;  
  const lenSq = C * C + D * D;  
  let param = -1;  

  if (lenSq !== 0) param = dot / lenSq;  

  let xx, yy;  

  if (param < 0) {  
    // 点在线段外侧,靠近起点  
    xx = x1;  
    yy = y1;  
  } else if (param > 1) {  
    // 点在线段外侧,靠近终点  
    xx = x2;  
    yy = y2;  
  } else {  
    // 点在线段范围内  
    xx = x1 + param * C;  
    yy = y1 + param * D;  
  }  

  const dx = px - xx;  
  const dy = py - yy;  
  return Math.sqrt(dx * dx + dy * dy);  
};

这个算法的核心思路:

  1. 把点投影到直线上
  2. 判断投影点是不是在线段范围内
  3. 算出点到投影点的距离

然后在touchstart判断:

if (currentTool.value === 'eraser') {  
  const clickX = coords.x;  
  const clickY = coords.y;  
  const eraseRadius = 30; // 橡皮擦范围  

  for (let i = drawings.value.length - 1; i >= 0; i--) {  
    const drawing = drawings.value[i];  
    let isNear = false;  

    if (drawing.type === 'line' || drawing.type === 'arrow') {  
      const dist = pointToLineDistance(  
        clickX, clickY,  
        drawing.startX, drawing.startY,  
        drawing.endX, drawing.endY  
      );  
      isNear = dist < eraseRadius;  
    } else if (drawing.type === 'curve') {  
      // 曲线:检查是否靠近任意点  
      for (let point of drawing.points) {  
        const dist = Math.sqrt(  
          Math.pow(clickX - point.x, 2) + Math.pow(clickY - point.y, 2)  
        );  
        if (dist < eraseRadius) {  
          isNear = true;  
          break;  
        }  
      }  
    }  

    if (isNear) {  
      drawings.value.splice(i, 1);  
      redrawCanvas();  
      break;  
    }  
  }  
}

重绘画布 - 性能优化

每次操作都要重绘整个画布,顺序很重要:

const redrawCanvas = () => {  
  if (!ctx.value) return;  

  // 1. 清空画布(但不渲染)  
  ctx.value.clearRect(0, 0, canvasWidth.value, canvasHeight.value);  

  // 2. 绘制所有已保存的图形  
  drawings.value.forEach((drawing) => {  
    drawShape(drawing);  
  });  

  // 3. 绘制当前正在画的(实时反馈)  
  if (currentDrawing.value) {  
    drawShape(currentDrawing.value);  
  }  

  // 4. 旧API必须调用draw()才能渲染到屏幕  
  if (!ctx.value.isCanvas2d) {  
    ctx.value.draw(false); // false表示不清空之前的内容  
  }  
};

注意:

  • 新API(Canvas 2D):每次绘制都是实时的
  • 旧API:必须调用draw()才会显示
  • 鸿蒙用的是旧API,别忘了draw()
  • draw(false)的false很关键,true会清空画布

导出图片 - 生成分享海报

这个功能挺实用的,步骤是这样的:

  1. 先截取战术板:把球场、球员、战术线都画到临时canvas上
  2. 再画海报:在另一个canvas上画标题、战术板图片、底部信息
  3. 导出为图片:用uni.canvasToTempFilePath

绘制战术板完整内容

const captureBoard = () => {  
  return new Promise((resolve, reject) => {  
    const tempCtx = uni.createCanvasContext("tacticsCanvas");  
    tempCtx.isCanvas2d = false;  

    // 1. 画背景(球场)  
    tempCtx.setFillStyle("#2d8659");  
    tempCtx.fillRect(0, 0, boardWidth, boardHeight);  

    // 2. 画场地线(中线、禁区等)  
    tempCtx.setStrokeStyle("rgba(255, 255, 255, 0.6)");  
    tempCtx.setLineWidth(2);  
    tempCtx.beginPath();  
    tempCtx.moveTo(0, boardHeight * 0.5);  
    tempCtx.lineTo(boardWidth, boardHeight * 0.5);  
    tempCtx.stroke();  

    // 中圈  
    const centerCircleRadius = (100 / 750) * boardWidth;  
    tempCtx.beginPath();  
    tempCtx.arc(boardWidth * 0.5, boardHeight * 0.5, centerCircleRadius, 0, 2 * Math.PI);  
    tempCtx.stroke();  

    // ... 更多场地线  

    // 3. 画战术线路  
    drawings.value.forEach((drawing) => {  
      drawShape(drawing, tempCtx);  
    });  

    // 4. 画球员  
    playersOnField.value.forEach((player) => {  
      const markerRadius = (60 / 2) * (screenWidth / 750);  
      const centerX = player.x + markerRadius;  
      const centerY = player.y + markerRadius;  

      // 圆圈  
      tempCtx.setFillStyle(player.team === 'home' ? '#1a3b6e' : '#ff6b35');  
      tempCtx.beginPath();  
      tempCtx.arc(centerX, centerY, markerRadius, 0, 2 * Math.PI);  
      tempCtx.fill();  

      // 号码  
      tempCtx.setFillStyle('#ffffff');  
      tempCtx.setFontSize(markerRadius * 0.8);  
      tempCtx.setTextAlign('center');  
      tempCtx.setTextBaseline('middle');  
      tempCtx.fillText(player.number, centerX, centerY);  
    });  

    // 5. 导出  
    tempCtx.draw(false, () => {  
      setTimeout(() => {  
        uni.canvasToTempFilePath({  
          canvasId: "tacticsCanvas",  
          success: (res) => {  
            resolve(res.tempFilePath);  
          },  
          fail: reject  
        });  
      }, 500); // 等渲染完成  
    });  
  });  
};

绘制海报

const drawSharePoster = (boardImagePath) => {  
  return new Promise((resolve, reject) => {  
    const pWidth = 375;  
    const pHeight = 550;  
    const pCtx = uni.createCanvasContext("sharePosterCanvas");  

    // 1. 背景渐变  
    const gradient = pCtx.createLinearGradient(0, 0, 0, pHeight);  
    gradient.addColorStop(0, "#1a3b6e");  
    gradient.addColorStop(1, "#2c5282");  
    pCtx.setFillStyle(gradient);  
    pCtx.fillRect(0, 0, pWidth, pHeight);  

    // 2. 标题  
    pCtx.setFillStyle("#ffffff");  
    pCtx.setFontSize(24);  
    pCtx.setTextAlign("center");  
    pCtx.fillText("⚽ 足球战术板", pWidth / 2, 40);  

    // 3. 副标题  
    pCtx.setFillStyle("rgba(255, 255, 255, 0.8)");  
    pCtx.setFontSize(14);  
    pCtx.fillText("Football Tactics Board", pWidth / 2, 65);  

    // 4. 战术板图片(重点!)  
    const margin = 20;  
    const maxBoardWidth = pWidth - margin * 2;  
    const boardAspect = boardRect.value.width / boardRect.value.height;  
    let drawWidth = maxBoardWidth;  
    let drawHeight = drawWidth / boardAspect;  

    const boardX = (pWidth - drawWidth) / 2;  
    const boardY = 90;  

    // 白色卡片背景  
    pCtx.setFillStyle("#ffffff");  
    pCtx.fillRect(boardX - 8, boardY - 8, drawWidth + 16, drawHeight + 16);  

    // 画战术板图片  
    pCtx.drawImage(boardImagePath, boardX, boardY, drawWidth, drawHeight);  

    // 5. 底部信息  
    const footerY = pHeight - 50;  
    pCtx.setFillStyle("rgba(255, 255, 255, 0.9)");  
    pCtx.setFontSize(16);  
    pCtx.fillText("苏超排名助手", pWidth / 2, footerY);  

    const now = new Date();  
    const timeStr = `${now.getFullYear()}-${now.getMonth()+1}-${now.getDate()}`;  
    pCtx.setFillStyle("rgba(255, 255, 255, 0.5)");  
    pCtx.setFontSize(10);  
    pCtx.fillText(`生成时间: ${timeStr}`, pWidth / 2, footerY + 25);  

    // 6. 导出高清图(2倍分辨率)  
    pCtx.draw(false, () => {  
      setTimeout(() => {  
        uni.canvasToTempFilePath({  
          canvasId: "sharePosterCanvas",  
          width: pWidth,  
          height: pHeight,  
          destWidth: pWidth * 2,  // 2倍分辨率  
          destHeight: pHeight * 2,  
          fileType: "png",  
          quality: 1,  
          success: (res) => resolve(res.tempFilePath),  
          fail: reject  
        });  
      }, 800);  
    });  
  });  
};

注意destWidthdestHeight,设置成2倍能提高导出图片的清晰度。

踩过的坑总结

1. 初始化时机问题

症状:获取不到canvas尺寸,或者坐标转换不准

原因:页面还没渲染完就初始化了

解决:延迟200ms,再用createSelectorQuery获取真实位置

setTimeout(() => {  
  const query = uni.createSelectorQuery();  
  query.select(".canvas-board").boundingClientRect((rect) => {  
    boardRect.value = rect;  
    canvasReady.value = true;  
  }).exec();  
}, 200);

2. 坐标转换不准确

症状:画的位置跟手指点的位置对不上

原因:没考虑canvas的偏移量(padding、margin等)

解决:必须用boundingClientRect获取真实位置,然后减去偏移

x = touch.clientX - boardRect.value.left;  
y = touch.clientY - boardRect.value.top;

3. 鸿蒙draw()必须调用

症状:画了但是屏幕上看不到

原因:旧API必须调用draw()才会渲染

解决:每次绘制完都调用

if (!ctx.value.isCanvas2d) {  
  ctx.value.draw(false);  
}

4. 导出图片时机问题

症状:导出的图片是空白的或者不完整

原因draw()是异步的,还没渲染完就导出了

解决:在draw()的回调里,再延迟500ms

ctx.draw(false, () => {  
  setTimeout(() => {  
    uni.canvasToTempFilePath({...});  
  }, 500);  
});

5. 橡皮擦范围不好控制

症状:点了删不掉,或者不小心删了别的线

原因:距离阈值设置不合理

解决:30-40px比较合适,太小不好点,太大容易误删

const eraseRadius = 30; // 橡皮擦范围  
if (dist < eraseRadius) {  
  // 删除  
}

6. touchmove和页面滚动冲突

症状:画线的时候页面跟着滚动

原因:touchmove事件冒泡到了页面

解决:加.stop.prevent修饰符

<canvas @touchmove.stop.prevent="handleCanvasTouchMove"></canvas>

但是鸿蒙不支持.prevent,要用disable-scroll="true"

<!-- #ifdef APP-HARMONY -->  
<canvas disable-scroll="true"></canvas>  
<!-- #endif -->

7. 重绘性能问题

症状:线条多了之后很卡

原因:每次touchmove都要重绘所有线条

解决

  • 限制绘制对象数量(最多50个)
  • touchmove节流(每10ms更新一次)
  • 复杂背景用离屏canvas缓存
let lastTime = 0;  
const handleCanvasTouchMove = (e) => {  
  const now = Date.now();  
  if (now - lastTime < 10) return; // 节流  
  lastTime = now;  
  // ...  
};

8. 图片清晰度问题

症状:导出的图片很模糊

原因:没设置destWidthdestHeight

解决:设置成实际尺寸的2倍

uni.canvasToTempFilePath({  
  canvasId: "myCanvas",  
  destWidth: width * 2,  
  destHeight: height * 2,  
  fileType: "png",  
  quality: 1  
});

性能优化建议

1. 节流touchmove

绘制曲线时,不用每次touchmove都添加点:

let lastTime = 0;  
const handleCanvasTouchMove = (e) => {  
  const now = Date.now();  
  if (now - lastTime < 16) return; // 约60fps  
  lastTime = now;  

  const coords = getCanvasCoords(e.touches[0]);  
  currentDrawing.value.points.push(coords);  
  redrawCanvas();  
};

2. 限制绘制数量

太多绘制对象会导致重绘变慢:

const MAX_DRAWINGS = 50;  

const handleCanvasTouchEnd = () => {  
  if (isDrawing.value && currentDrawing.value) {  
    drawings.value.push({ ...currentDrawing.value });  

    // 限制数量  
    if (drawings.value.length > MAX_DRAWINGS) {  
      drawings.value.shift(); // 删除最早的  
    }  

    redrawCanvas();  
  }  
};

3. 离屏canvas缓存背景

球场背景每次都画很费性能,可以缓存起来:

let backgroundCache = null;  

const cacheBackground = () => {  
  const offscreenCanvas = uni.createOffscreenCanvas({  
    type: '2d',  
    width: canvasWidth.value,  
    height: canvasHeight.value  
  });  

  const offCtx = offscreenCanvas.getContext('2d');  

  // 画球场背景  
  offCtx.fillStyle = '#2d8659';  
  offCtx.fillRect(0, 0, canvasWidth.value, canvasHeight.value);  
  // ... 画其他场地线  

  backgroundCache = offscreenCanvas;  
};  

const redrawCanvas = () => {  
  if (!ctx.value) return;  

  ctx.value.clearRect(0, 0, canvasWidth.value, canvasHeight.value);  

  // 用缓存的背景  
  if (backgroundCache) {  
    ctx.value.drawImage(backgroundCache, 0, 0);  
  }  

  // 画战术线  
  drawings.value.forEach(drawing => drawShape(drawing));  

  if (!ctx.value.isCanvas2d) {  
    ctx.value.draw(false);  
  }  
};

4. 按需重绘

如果只是改变某个元素,可以不重绘整个画布(但Canvas API不支持局部重绘,这个比较难实现)。

替代方案:把不同层分开

  • 背景层:静态的球场(很少变化)
  • 战术层:箭头、线条(经常变化)
  • 球员层:用普通DOM而不是Canvas
<!-- 背景canvas -->  
<canvas id="bgCanvas" class="bg-layer"></canvas>  

<!-- 战术canvas -->  
<canvas id="tacticsCanvas" class="tactics-layer"></canvas>  

<!-- 球员用普通DOM -->  
<view class="players-layer">  
  <view v-for="player in players" :key="player.id"   
        :style="{left: player.x, top: player.y}">  
    {{ player.number }}  
  </view>  
</view>

这样球员拖动就不需要重绘canvas了。

实用技巧

1. 调试坐标

在开发时可以实时显示坐标,方便调试:

const handleCanvasTouchMove = (e) => {  
  const coords = getCanvasCoords(e.touches[0]);  
  console.log(`x: ${coords.x}, y: ${coords.y}`);  
  // 或者显示在页面上  
  debugInfo.value = `x: ${coords.x}, y: ${coords.y}`;  
};

2. 撤销功能

保存历史记录数组:

const history = ref([]);  
const historyIndex = ref(-1);  

const saveHistory = () => {  
  // 删除当前索引之后的历史  
  history.value = history.value.slice(0, historyIndex.value + 1);  
  // 添加新状态  
  history.value.push(JSON.parse(JSON.stringify(drawings.value)));  
  historyIndex.value++;  

  // 限制历史记录数量  
  if (history.value.length > 20) {  
    history.value.shift();  
    historyIndex.value--;  
  }  
};  

const undo = () => {  
  if (historyIndex.value > 0) {  
    historyIndex.value--;  
    drawings.value = JSON.parse(JSON.stringify(history.value[historyIndex.value]));  
    redrawCanvas();  
  }  
};  

const redo = () => {  
  if (historyIndex.value < history.value.length - 1) {  
    historyIndex.value++;  
    drawings.value = JSON.parse(JSON.stringify(history.value[historyIndex.value]));  
    redrawCanvas();  
  }  
};

3. 颜色选择器

让用户自定义线条颜色:

const colors = ['#ff6b35', '#1a3b6e', '#4caf50', '#f44336', '#9e9e9e'];  
const currentColor = ref('#ff6b35');  

const handleCanvasTouchStart = (e) => {  
  // ...  
  currentDrawing.value = {  
    type: currentTool.value,  
    startX: coords.x,  
    startY: coords.y,  
    color: currentColor.value // 使用选中的颜色  
  };  
};

4. 线条粗细调整

const lineWidths = [2, 4, 6, 8];  
const currentWidth = ref(4);  

const drawShape = (shape) => {  
  // ...  
  const lineWidth = shape.width || 4;  
  setLineWidth(lineWidth);  
  // ...  
};

最后

整个功能做下来,最大的感受就是:Canvas在uni-app编译到鸿蒙还是有不少坑的。不过搞定之后还挺有成就感的。

开发建议

  1. 先在H5上调试,等逻辑都对了再适配鸿蒙
  2. 鸿蒙的真机调试很慢,多用console.loguni.showToast
  3. 坐标问题一定要用实际设备测试,模拟器不准
  4. 多保存代码,Canvas相关的代码很容易改崩

适用场景

  • 战术板(足球、篮球等)
  • 签名功能
  • 涂鸦板
  • 路径规划
  • 图片标注

代码在附件,有问题可以留言交流。希望能帮到要做类似功能的兄弟们!

收起阅读 »

表情包搜索助手:uni-app 鸿蒙应用开发全流程解析

鸿蒙征文

写在前面

这是一个用uni-app开发的表情包搜索应用,最大的亮点就是能编译运行到华为鸿蒙系统。说白了,就是用一套代码,可以在iOS、Android、小程序、鸿蒙等多个平台上跑起来。

这个教程是关于这个项目是怎么组织的,用了哪些技术,以及如何编译到鸿蒙系统。


一、整体架构概览

1.1 项目是干啥的?

这个APP就是一个表情包搜索工具,用户可以:

  • 浏览热门表情包
  • 搜索想要的表情
  • 查看专辑分类
  • 下载表情包保存到相册

很简单,就是这四大功能。

1.2 技术栈选型

咱们用的技术栈如下:

前端框架

  • Vue 3 - 这是uni-app采用的最新版Vue框架
  • uni-app - DCloud家的跨平台框架,可以把代码编译到各个平台
  • Pinia - Vue 3推荐的状态管理工具(相当于Vuex的升级版)

开发语言

  • JavaScript/ES6+ - 主要业务逻辑
  • UTS (uni TypeScript) - 用来写鸿蒙原生插件的语言

UI组件

  • 原生组件 - 直接用uni-app的内置组件
  • 自定义组件 - 比如协议弹窗组件

网络请求

  • 基于uni.request封装的HTTP请求库
  • Promise风格,支持拦截器

二、项目目录结构

先看看整个项目的目录结构,这样心里有个底:

biaoqingbaosousuogit/  
├── common/                    # 公共模块  
│   ├── api/                  # 接口管理  
│   │   ├── http.js          # 封装的HTTP请求库  
│   │   └── base.js          # 业务接口定义  
│   └── util/                # 工具函数  
│       ├── datetime.js      # 日期时间处理  
│       └── util.js          # 通用工具方法  
│  
├── components/               # 组件库  
│   └── agreement-popup/     # 用户协议弹窗组件  
│  
├── pages/                    # 页面目录  
│   ├── index/               # 首页(热门表情)  
│   ├── album/               # 专辑页  
│   ├── search/              # 搜索页  
│   ├── detail/              # 详情页  
│   └── webview/             # H5页面容器  
│  
├── stores/                   # 状态管理(Pinia)  
│   └── main.js              # 主store  
│  
├── static/                   # 静态资源  
│   ├── icon/                # 图标  
│   ├── image/               # 图片  
│   └── tabbar/              # 底部导航图标  
│  
├── uni_modules/             # uni插件模块  
│   └── ha-downloadToSystemAlbum/  # 鸿蒙下载插件  
│       └── utssdk/  
│           └── app-harmony/ # 鸿蒙原生实现  
│  
├── harmony-configs/         # 鸿蒙配置(重点!)  
│   ├── build-profile.json5 # 构建配置、签名证书  
│   ├── AppScope/           # 应用级配置  
│   └── entry/              # 入口模块配置  
│  
├── App.vue                  # 应用入口  
├── main.js                  # 主入口文件  
├── pages.json              # 页面路由配置  
├── manifest.json           # 应用配置清单  
└── env.js                  # 环境配置

目录功能说明

common/ - 公共模块,存放各种通用的东西

  • api/ - 网络请求相关,包括封装好的请求库和所有API接口
  • util/ - 工具函数,比如日期格式化之类的

pages/ - 存放所有的页面,每个文件夹就是一个页面模块

stores/ - Pinia状态管理,用来存放全局状态数据(比如token、版本号等)

harmony-configs/ - 这个很重要!专门为鸿蒙系统准备的配置文件,包含签名证书、权限声明等

uni_modules/ - uni-app的插件系统,这里有个自定义的鸿蒙下载插件


三、核心模块详解

3.1 网络请求封装(common/api/http.js)

这个文件封装了一个通用的HTTP请求库,解决几个问题:

  1. 统一的请求配置

    • baseUrl配置
    • 默认请求头
    • 超时时间
  2. 请求拦截

    • 自动添加token
    • 自动添加版本号
    • 添加平台标识
  3. 响应处理

    • 统一的错误处理
    • 日志记录

代码结构大概这样:

export default {  
  config: {  
    baseUrl: baseUrl,  
    timeout: 10000,  
    // ...其他配置  
  },  

  request(options) {  
    // 从store获取token、版本号等信息  
    const mainStore = useMainStore();  

    // 组装请求头  
    options.header = Object.assign({}, options.header, {  
      Version: mainStore.version,  
      Authorization: "Bearer " + mainStore.token,  
      UniPlatform: mainStore.uniPlatform  
    });  

    // 返回Promise  
    return new Promise((resolve, reject) => {  
      uni.request({  
        ...options,  
        complete: (response) => {  
          if (response.statusCode === 200) {  
            resolve(response.data);  
          } else {  
            reject(response.data);  
          }  
        }  
      });  
    });  
  },  

  get(url, data, options) { /* ... */ },  
  post(url, data, options) { /* ... */ },  
  // ...其他方法  
}

使用起来很方便:

import api from '@/common/api/base.js'  

// 发起请求  
const res = await api.homeRandom({ page: 1 })

3.2 业务接口管理(common/api/base.js)

把所有的API接口集中管理,方便维护:

const homeAlbum = (data) => {  
  return http.request({  
    url: "/addon/face/home/album",  
    method: "GET",  
    data: data,  
  });  
};  

const homeRandom = (data) => {  
  return http.request({  
    url: "/addon/face/home/random",  
    method: "GET",  
    data: data,  
  });  
};  

// 导出所有接口  
export default {  
  homeAlbum,  
  homeRandom,  
  homeSearch,  
  homeRelate,  
  homeAlbumList,  
  homeDownload,  
};

这样做的好处:

  • 接口集中管理,一目了然
  • 修改接口时只需改这一个地方
  • 其他地方import进来直接用

3.3 状态管理(stores/main.js)

用Pinia来管理全局状态,代码很简单:

import { defineStore } from 'pinia';  

export const useMainStore = defineStore('main', {  
  state: () => ({  
    count: 0,  
    token: '',          // 用户token  
    version: '',        // 应用版本  
    uniPlatform: '',    // 运行平台  
  }),  
  actions: {  
    increment() {  
      this.count++;  
    },  
  },  
});

使用方式:

import { useMainStore } from '@/stores/main'  

const mainStore = useMainStore()  
console.log(mainStore.token)  // 读取  
mainStore.token = 'xxx'       // 写入

比Vuex简单多了,不需要写那么多模板代码。


四、页面实现详解

4.1 首页(pages/index/index.vue)

首页是个瀑布流展示热门表情的页面,核心功能:

  1. 瀑布流加载

    • 首次加载热门表情
    • 滚动到底部自动加载更多
  2. 返回顶部

    • 滚动超过500px显示返回顶部按钮
  3. 用户协议

    • 首次打开显示用户协议弹窗

关键代码片段:

// 获取热门表情  
const getRandomEmojis = async (isLoadMore = false) => {  
  if (loading.value || !hasMore.value) return;  

  loading.value = true;  
  try {  
    const res = await api.homeRandom({ page: page.value });  

    if (res.code === 200) {  
      if (isLoadMore) {  
        emojis.value = [...emojis.value, ...res.data];  
      } else {  
        emojis.value = res.data;  
      }  

      hasMore.value = res.data.length > 0;  
      if (hasMore.value) {  
        page.value++;  
      }  
    }  
  } catch (e) {  
    console.error('获取热门表情失败:', e);  
  } finally {  
    loading.value = false;  
  }  
};  

// 页面触底加载更多  
onReachBottom(() => {  
  getRandomEmojis(true);  
});

技巧点:

  • loadinghasMore标记来避免重复请求
  • isLoadMore参数区分首次加载和追加加载
  • onReachBottom是uni-app提供的触底钩子

4.2 详情页(pages/detail/detail.vue)

详情页展示单个表情包,可以下载保存,还会推荐相关的表情。

关键功能:

  1. 接收参数

    onLoad((options) => {  
     if (options.item) {  
       emojiData.value = JSON.parse(decodeURIComponent(options.item))  
     }  
    })  
  2. 下载保存

    const saveImage = () => {  
     uni.downloadFile({  
       url: emojiData.value.imgurl,  
       success: (res) => {  
         if (res.statusCode === 200) {  
           uni.saveImageToPhotosAlbum({  
             filePath: res.tempFilePath,  
             success: () => {  
               uni.showToast({ title: '保存成功', icon: 'success' })  
             }  
           })  
         }  
       }  
     })  
    }  
  3. 相关推荐

    • 调用api.homeRelate获取相关表情
    • 点击推荐项时切换当前表情并刷新推荐列表

五、鸿蒙适配核心技术

这部分是重点中的重点!如何让uni-app编译到鸿蒙系统。

5.1 鸿蒙配置目录(harmony-configs/)

这个目录是专门为鸿蒙准备的,uni-app在编译鸿蒙版本时会读取这些配置。

目录结构:

harmony-configs/  
├── build-profile.json5      # 构建配置  
├── AppScope/  
│   ├── app.json5           # 应用信息  
│   └── resources/          # 应用资源(图标等)  
└── entry/  
    └── src/main/  
        └── module.json5    # 模块配置(权限等)

5.2 构建配置(build-profile.json5)

这个文件配置签名证书和SDK版本,非常重要:

{  
  "app": {  
    "signingConfigs": [  
      {  
        "name": "default",  
        "type": "HarmonyOS",  
        "material": {  
          "storePassword": "你的证书库密码",  
          "certpath": "你的证书文件路径",  
          "keyAlias": "密钥别名",  
          "keyPassword": "密钥密码",  
          "profile": "配置文件路径",  
          "signAlg": "SHA256withECDSA",  
          "storeFile": "证书库文件路径"  
        }  
      }  
    ],  
    "products": [  
      {  
        "name": "default",  
        "signingConfig": "default",  
        "compatibleSdkVersion": "5.0.1(13)",  // SDK版本  
        "runtimeOS": "HarmonyOS"  
      }  
    ]  
  }  
}

重点:

  • signingConfigs - 配置开发和发布证书
  • compatibleSdkVersion - 兼容的鸿蒙SDK版本
  • 证书文件需要从华为AGC控制台申请

5.3 应用配置(AppScope/app.json5)

配置应用的基本信息:

{  
  "app": {  
    "bundleName": "com.letwind.biaoqingbaozhushou",  // 包名  
    "vendor": "letwind",  
    "versionCode": 101,  
    "versionName": "1.0.1",  
    "icon": "$media:app_icon",  
    "label": "$string:app_name"  
  }  
}

5.4 模块配置(entry/src/main/module.json5)

配置应用权限和能力:

{  
  "module": {  
    "name": "entry",  
    "type": "entry",  
    "deviceTypes": ["phone"],  
    "requestPermissions": [  
      {  
        "name": "ohos.permission.INTERNET"  // 网络权限  
      }  
    ]  
  }  
}

5.5 manifest.json中的鸿蒙配置

在uni-app的manifest.json里也要配置鸿蒙相关信息:

{  
  "vueVersion": "3",  
  "app-harmony": {  
    "distribute": {  
      "bundleName": "com.letwind.biaoqingbaozhushou",  
      "icons": {  
        "foreground": "static/icon/logo1024.png",  
        "background": "static/icon/logo1024.png"  
      }  
    }  
  }  
}

这里的bundleName要和app.json5里的保持一致!


六、编译运行到鸿蒙

6.1 环境准备

  1. 安装HBuilderX

    • 去DCloud官网下载最新版HBuilderX
    • 必须是支持鸿蒙的版本
  2. 准备证书

    • 去华为AGC控制台申请证书

6.2 配置证书

编辑harmony-configs/build-profile.json5

{  
  "app": {  
    "signingConfigs": [  
      {  
        "name": "default",  
        "material": {  
          "storePassword": "实际的证书库密码",  
          "certpath": "证书文件绝对路径/cert.cer",  
          "keyAlias": "实际的密钥别名",  
          "keyPassword": "实际的密钥密码",  
          "profile": "配置文件绝对路径/profile.p7b",  
          "signAlg": "SHA256withECDSA",  
          "storeFile": "证书库文件绝对路径/cert.p12"  
        }  
      }  
    ]  
  }  
}

6.3 运行项目

  1. 在HBuilderX中打开项目
  2. 点击工具栏的"运行"
  3. 选择"运行到鸿蒙"
  4. 选择设备
  5. 等待编译完成,APP会自动安装到设备上

6.4 打包发布

  1. 配置发布证书

    • signingConfigs中配置release证书
    • 发布证书要在AGC控制台单独申请
  2. 本地打包

    • 点击HBuilderX的"发行"菜单
    • 选择"鸿蒙本地打包"
    • 生成.app文件
  3. 上架应用市场

    • 登录华为应用市场控制台
    • 上传.app文件
    • 填写应用信息
    • 提交审核

七、常见问题和解决方案

7.1 证书配置错误

问题: 编译时报错"签名失败"

解决:

  • 确认证书文件路径正确(建议用绝对路径)
  • 检查证书密码是否正确
  • 确认证书和profile文件是匹配的
  • 检查bundleName是否和证书中的包名一致

7.2 设备识别不了

问题: HBuilderX检测不到鸿蒙设备

解决:

  • 确认手机已开启开发者模式和USB调试
  • 更换数据线(有些数据线只能充电不能传输数据)
  • 重启HBuilderX和手机
  • 检查是否安装了华为手机驱动

7.3 页面跳转失败

问题: 在鸿蒙上页面跳转不成功

解决:

  • 检查pages.json中是否已注册该页面
  • 检查跳转路径是否正确(不要带.vue后缀)
  • 如果是tabBar页面,要用uni.switchTab而不是uni.navigateTo

八、项目亮点总结

8.1 跨平台能力

一套代码,多端运行:

  • iOS APP
  • Android APP
  • 鸿蒙APP(HarmonyOS NEXT)
  • 微信小程序
  • H5网页

这就是uni-app的最大价值,开发效率极高。

8.2 鸿蒙适配方案

完整的鸿蒙适配解决方案:

  • 标准的配置文件结构
  • 签名证书管理
  • 系统API调用(相册、下载等)

可以作为其他uni-app项目适配鸿蒙的参考模板。

8.3 工程化实践

规范的项目结构:

  • 清晰的目录划分
  • API集中管理
  • 组件化开发
  • 状态统一管理

代码可维护性强,方便团队协作。


写在最后

这个项目虽然功能不算复杂,但麻雀虽小五脏俱全,把uni-app开发鸿蒙应用的整套流程都走通了。

关键点就这几个:

  1. uni-app框架 - 跨平台的基础
  2. harmony-configs - 鸿蒙配置的核心
  3. 证书签名 - 打包发布的关键

只要搞懂这几个点,你也能把自己的uni-app项目跑在鸿蒙上。

如果遇到问题,多看看官方文档,或者加入开发者社群交流。鸿蒙生态还在快速发展,遇到坑是正常的,解决问题的过程也是成长的过程。

加油!🚀


项目开源地址

GitHub: https://github.com/zwpro/uniapptohongmeng

欢迎 Star、Fork 和提交 Issue!

继续阅读 »

写在前面

这是一个用uni-app开发的表情包搜索应用,最大的亮点就是能编译运行到华为鸿蒙系统。说白了,就是用一套代码,可以在iOS、Android、小程序、鸿蒙等多个平台上跑起来。

这个教程是关于这个项目是怎么组织的,用了哪些技术,以及如何编译到鸿蒙系统。


一、整体架构概览

1.1 项目是干啥的?

这个APP就是一个表情包搜索工具,用户可以:

  • 浏览热门表情包
  • 搜索想要的表情
  • 查看专辑分类
  • 下载表情包保存到相册

很简单,就是这四大功能。

1.2 技术栈选型

咱们用的技术栈如下:

前端框架

  • Vue 3 - 这是uni-app采用的最新版Vue框架
  • uni-app - DCloud家的跨平台框架,可以把代码编译到各个平台
  • Pinia - Vue 3推荐的状态管理工具(相当于Vuex的升级版)

开发语言

  • JavaScript/ES6+ - 主要业务逻辑
  • UTS (uni TypeScript) - 用来写鸿蒙原生插件的语言

UI组件

  • 原生组件 - 直接用uni-app的内置组件
  • 自定义组件 - 比如协议弹窗组件

网络请求

  • 基于uni.request封装的HTTP请求库
  • Promise风格,支持拦截器

二、项目目录结构

先看看整个项目的目录结构,这样心里有个底:

biaoqingbaosousuogit/  
├── common/                    # 公共模块  
│   ├── api/                  # 接口管理  
│   │   ├── http.js          # 封装的HTTP请求库  
│   │   └── base.js          # 业务接口定义  
│   └── util/                # 工具函数  
│       ├── datetime.js      # 日期时间处理  
│       └── util.js          # 通用工具方法  
│  
├── components/               # 组件库  
│   └── agreement-popup/     # 用户协议弹窗组件  
│  
├── pages/                    # 页面目录  
│   ├── index/               # 首页(热门表情)  
│   ├── album/               # 专辑页  
│   ├── search/              # 搜索页  
│   ├── detail/              # 详情页  
│   └── webview/             # H5页面容器  
│  
├── stores/                   # 状态管理(Pinia)  
│   └── main.js              # 主store  
│  
├── static/                   # 静态资源  
│   ├── icon/                # 图标  
│   ├── image/               # 图片  
│   └── tabbar/              # 底部导航图标  
│  
├── uni_modules/             # uni插件模块  
│   └── ha-downloadToSystemAlbum/  # 鸿蒙下载插件  
│       └── utssdk/  
│           └── app-harmony/ # 鸿蒙原生实现  
│  
├── harmony-configs/         # 鸿蒙配置(重点!)  
│   ├── build-profile.json5 # 构建配置、签名证书  
│   ├── AppScope/           # 应用级配置  
│   └── entry/              # 入口模块配置  
│  
├── App.vue                  # 应用入口  
├── main.js                  # 主入口文件  
├── pages.json              # 页面路由配置  
├── manifest.json           # 应用配置清单  
└── env.js                  # 环境配置

目录功能说明

common/ - 公共模块,存放各种通用的东西

  • api/ - 网络请求相关,包括封装好的请求库和所有API接口
  • util/ - 工具函数,比如日期格式化之类的

pages/ - 存放所有的页面,每个文件夹就是一个页面模块

stores/ - Pinia状态管理,用来存放全局状态数据(比如token、版本号等)

harmony-configs/ - 这个很重要!专门为鸿蒙系统准备的配置文件,包含签名证书、权限声明等

uni_modules/ - uni-app的插件系统,这里有个自定义的鸿蒙下载插件


三、核心模块详解

3.1 网络请求封装(common/api/http.js)

这个文件封装了一个通用的HTTP请求库,解决几个问题:

  1. 统一的请求配置

    • baseUrl配置
    • 默认请求头
    • 超时时间
  2. 请求拦截

    • 自动添加token
    • 自动添加版本号
    • 添加平台标识
  3. 响应处理

    • 统一的错误处理
    • 日志记录

代码结构大概这样:

export default {  
  config: {  
    baseUrl: baseUrl,  
    timeout: 10000,  
    // ...其他配置  
  },  

  request(options) {  
    // 从store获取token、版本号等信息  
    const mainStore = useMainStore();  

    // 组装请求头  
    options.header = Object.assign({}, options.header, {  
      Version: mainStore.version,  
      Authorization: "Bearer " + mainStore.token,  
      UniPlatform: mainStore.uniPlatform  
    });  

    // 返回Promise  
    return new Promise((resolve, reject) => {  
      uni.request({  
        ...options,  
        complete: (response) => {  
          if (response.statusCode === 200) {  
            resolve(response.data);  
          } else {  
            reject(response.data);  
          }  
        }  
      });  
    });  
  },  

  get(url, data, options) { /* ... */ },  
  post(url, data, options) { /* ... */ },  
  // ...其他方法  
}

使用起来很方便:

import api from '@/common/api/base.js'  

// 发起请求  
const res = await api.homeRandom({ page: 1 })

3.2 业务接口管理(common/api/base.js)

把所有的API接口集中管理,方便维护:

const homeAlbum = (data) => {  
  return http.request({  
    url: "/addon/face/home/album",  
    method: "GET",  
    data: data,  
  });  
};  

const homeRandom = (data) => {  
  return http.request({  
    url: "/addon/face/home/random",  
    method: "GET",  
    data: data,  
  });  
};  

// 导出所有接口  
export default {  
  homeAlbum,  
  homeRandom,  
  homeSearch,  
  homeRelate,  
  homeAlbumList,  
  homeDownload,  
};

这样做的好处:

  • 接口集中管理,一目了然
  • 修改接口时只需改这一个地方
  • 其他地方import进来直接用

3.3 状态管理(stores/main.js)

用Pinia来管理全局状态,代码很简单:

import { defineStore } from 'pinia';  

export const useMainStore = defineStore('main', {  
  state: () => ({  
    count: 0,  
    token: '',          // 用户token  
    version: '',        // 应用版本  
    uniPlatform: '',    // 运行平台  
  }),  
  actions: {  
    increment() {  
      this.count++;  
    },  
  },  
});

使用方式:

import { useMainStore } from '@/stores/main'  

const mainStore = useMainStore()  
console.log(mainStore.token)  // 读取  
mainStore.token = 'xxx'       // 写入

比Vuex简单多了,不需要写那么多模板代码。


四、页面实现详解

4.1 首页(pages/index/index.vue)

首页是个瀑布流展示热门表情的页面,核心功能:

  1. 瀑布流加载

    • 首次加载热门表情
    • 滚动到底部自动加载更多
  2. 返回顶部

    • 滚动超过500px显示返回顶部按钮
  3. 用户协议

    • 首次打开显示用户协议弹窗

关键代码片段:

// 获取热门表情  
const getRandomEmojis = async (isLoadMore = false) => {  
  if (loading.value || !hasMore.value) return;  

  loading.value = true;  
  try {  
    const res = await api.homeRandom({ page: page.value });  

    if (res.code === 200) {  
      if (isLoadMore) {  
        emojis.value = [...emojis.value, ...res.data];  
      } else {  
        emojis.value = res.data;  
      }  

      hasMore.value = res.data.length > 0;  
      if (hasMore.value) {  
        page.value++;  
      }  
    }  
  } catch (e) {  
    console.error('获取热门表情失败:', e);  
  } finally {  
    loading.value = false;  
  }  
};  

// 页面触底加载更多  
onReachBottom(() => {  
  getRandomEmojis(true);  
});

技巧点:

  • loadinghasMore标记来避免重复请求
  • isLoadMore参数区分首次加载和追加加载
  • onReachBottom是uni-app提供的触底钩子

4.2 详情页(pages/detail/detail.vue)

详情页展示单个表情包,可以下载保存,还会推荐相关的表情。

关键功能:

  1. 接收参数

    onLoad((options) => {  
     if (options.item) {  
       emojiData.value = JSON.parse(decodeURIComponent(options.item))  
     }  
    })  
  2. 下载保存

    const saveImage = () => {  
     uni.downloadFile({  
       url: emojiData.value.imgurl,  
       success: (res) => {  
         if (res.statusCode === 200) {  
           uni.saveImageToPhotosAlbum({  
             filePath: res.tempFilePath,  
             success: () => {  
               uni.showToast({ title: '保存成功', icon: 'success' })  
             }  
           })  
         }  
       }  
     })  
    }  
  3. 相关推荐

    • 调用api.homeRelate获取相关表情
    • 点击推荐项时切换当前表情并刷新推荐列表

五、鸿蒙适配核心技术

这部分是重点中的重点!如何让uni-app编译到鸿蒙系统。

5.1 鸿蒙配置目录(harmony-configs/)

这个目录是专门为鸿蒙准备的,uni-app在编译鸿蒙版本时会读取这些配置。

目录结构:

harmony-configs/  
├── build-profile.json5      # 构建配置  
├── AppScope/  
│   ├── app.json5           # 应用信息  
│   └── resources/          # 应用资源(图标等)  
└── entry/  
    └── src/main/  
        └── module.json5    # 模块配置(权限等)

5.2 构建配置(build-profile.json5)

这个文件配置签名证书和SDK版本,非常重要:

{  
  "app": {  
    "signingConfigs": [  
      {  
        "name": "default",  
        "type": "HarmonyOS",  
        "material": {  
          "storePassword": "你的证书库密码",  
          "certpath": "你的证书文件路径",  
          "keyAlias": "密钥别名",  
          "keyPassword": "密钥密码",  
          "profile": "配置文件路径",  
          "signAlg": "SHA256withECDSA",  
          "storeFile": "证书库文件路径"  
        }  
      }  
    ],  
    "products": [  
      {  
        "name": "default",  
        "signingConfig": "default",  
        "compatibleSdkVersion": "5.0.1(13)",  // SDK版本  
        "runtimeOS": "HarmonyOS"  
      }  
    ]  
  }  
}

重点:

  • signingConfigs - 配置开发和发布证书
  • compatibleSdkVersion - 兼容的鸿蒙SDK版本
  • 证书文件需要从华为AGC控制台申请

5.3 应用配置(AppScope/app.json5)

配置应用的基本信息:

{  
  "app": {  
    "bundleName": "com.letwind.biaoqingbaozhushou",  // 包名  
    "vendor": "letwind",  
    "versionCode": 101,  
    "versionName": "1.0.1",  
    "icon": "$media:app_icon",  
    "label": "$string:app_name"  
  }  
}

5.4 模块配置(entry/src/main/module.json5)

配置应用权限和能力:

{  
  "module": {  
    "name": "entry",  
    "type": "entry",  
    "deviceTypes": ["phone"],  
    "requestPermissions": [  
      {  
        "name": "ohos.permission.INTERNET"  // 网络权限  
      }  
    ]  
  }  
}

5.5 manifest.json中的鸿蒙配置

在uni-app的manifest.json里也要配置鸿蒙相关信息:

{  
  "vueVersion": "3",  
  "app-harmony": {  
    "distribute": {  
      "bundleName": "com.letwind.biaoqingbaozhushou",  
      "icons": {  
        "foreground": "static/icon/logo1024.png",  
        "background": "static/icon/logo1024.png"  
      }  
    }  
  }  
}

这里的bundleName要和app.json5里的保持一致!


六、编译运行到鸿蒙

6.1 环境准备

  1. 安装HBuilderX

    • 去DCloud官网下载最新版HBuilderX
    • 必须是支持鸿蒙的版本
  2. 准备证书

    • 去华为AGC控制台申请证书

6.2 配置证书

编辑harmony-configs/build-profile.json5

{  
  "app": {  
    "signingConfigs": [  
      {  
        "name": "default",  
        "material": {  
          "storePassword": "实际的证书库密码",  
          "certpath": "证书文件绝对路径/cert.cer",  
          "keyAlias": "实际的密钥别名",  
          "keyPassword": "实际的密钥密码",  
          "profile": "配置文件绝对路径/profile.p7b",  
          "signAlg": "SHA256withECDSA",  
          "storeFile": "证书库文件绝对路径/cert.p12"  
        }  
      }  
    ]  
  }  
}

6.3 运行项目

  1. 在HBuilderX中打开项目
  2. 点击工具栏的"运行"
  3. 选择"运行到鸿蒙"
  4. 选择设备
  5. 等待编译完成,APP会自动安装到设备上

6.4 打包发布

  1. 配置发布证书

    • signingConfigs中配置release证书
    • 发布证书要在AGC控制台单独申请
  2. 本地打包

    • 点击HBuilderX的"发行"菜单
    • 选择"鸿蒙本地打包"
    • 生成.app文件
  3. 上架应用市场

    • 登录华为应用市场控制台
    • 上传.app文件
    • 填写应用信息
    • 提交审核

七、常见问题和解决方案

7.1 证书配置错误

问题: 编译时报错"签名失败"

解决:

  • 确认证书文件路径正确(建议用绝对路径)
  • 检查证书密码是否正确
  • 确认证书和profile文件是匹配的
  • 检查bundleName是否和证书中的包名一致

7.2 设备识别不了

问题: HBuilderX检测不到鸿蒙设备

解决:

  • 确认手机已开启开发者模式和USB调试
  • 更换数据线(有些数据线只能充电不能传输数据)
  • 重启HBuilderX和手机
  • 检查是否安装了华为手机驱动

7.3 页面跳转失败

问题: 在鸿蒙上页面跳转不成功

解决:

  • 检查pages.json中是否已注册该页面
  • 检查跳转路径是否正确(不要带.vue后缀)
  • 如果是tabBar页面,要用uni.switchTab而不是uni.navigateTo

八、项目亮点总结

8.1 跨平台能力

一套代码,多端运行:

  • iOS APP
  • Android APP
  • 鸿蒙APP(HarmonyOS NEXT)
  • 微信小程序
  • H5网页

这就是uni-app的最大价值,开发效率极高。

8.2 鸿蒙适配方案

完整的鸿蒙适配解决方案:

  • 标准的配置文件结构
  • 签名证书管理
  • 系统API调用(相册、下载等)

可以作为其他uni-app项目适配鸿蒙的参考模板。

8.3 工程化实践

规范的项目结构:

  • 清晰的目录划分
  • API集中管理
  • 组件化开发
  • 状态统一管理

代码可维护性强,方便团队协作。


写在最后

这个项目虽然功能不算复杂,但麻雀虽小五脏俱全,把uni-app开发鸿蒙应用的整套流程都走通了。

关键点就这几个:

  1. uni-app框架 - 跨平台的基础
  2. harmony-configs - 鸿蒙配置的核心
  3. 证书签名 - 打包发布的关键

只要搞懂这几个点,你也能把自己的uni-app项目跑在鸿蒙上。

如果遇到问题,多看看官方文档,或者加入开发者社群交流。鸿蒙生态还在快速发展,遇到坑是正常的,解决问题的过程也是成长的过程。

加油!🚀


项目开源地址

GitHub: https://github.com/zwpro/uniapptohongmeng

欢迎 Star、Fork 和提交 Issue!

收起阅读 »

【鸿蒙征文】UniApp(X) 让鸿蒙开发触手可及 —— LimeUI 组件库开发简录

鸿蒙征文 鸿蒙

引言:鸿蒙开发,真的有那么难吗?🤔

当鸿蒙系统(HarmonyOS)带着"分布式"、"原生智能"等前沿概念亮相时,许多开发者,尤其是前端开发者,心中难免产生畏惧:学习全新的 ArkTS 语言、掌握 DevEco Studio IDE、理解复杂的系统架构……这些障碍,似乎构筑了一道难以逾越的高墙。

然而,这种复杂的印象可能只是一种误解。如果你已经熟悉 Vue.js,那么惊喜来了——你与鸿蒙应用开发之间的距离,其实只隔了一个 UniApp(X)。本文将结合我开发 LimeUI 组件库的实战经历,向你证明:借助 UniApp(X),鸿蒙应用开发真的可以"有手就行",让前端开发者轻松拥抱鸿蒙生态。

第一章:UniApp(X) —— 鸿蒙开发的高效解决方案 🚀

在着手为鸿蒙生态贡献组件库时,作为一名熟悉UniApp的开发者,我自然选择了UniApp(X)。正是因为它会编译为ArkTS,能保持与原生相同的性能,同时极大地降低了开发门槛:

1. 零门槛的语法亲和性 ✨

对于Vue开发者而言,UniApp(X)的语法几乎是零学习成本的。你可以继续使用熟悉的template-script-style结构,继续运用v-model、v-if等指令。这种无缝衔接的体验,让鸿蒙开发的"陌生感"瞬间烟消云散。

2. 智能的平台差异抹平机制 🔄

UniApp(X) 的核心魅力在于其强大的条件编译系统。在开发 LimeUI 组件库时,我的代码结构通常是这样的:

<template>  
    <view class="l-button" @click="handleClick">  
      <text>{{ text }}</text>  
    </view>  
</template>  
<script setup lang="uts">  
  import { ButtonProps } from './type';  
  const emit = defineEmits(['click'])  
  const props = withDefaults(defineProps<ButtonProps>(), {  
    disabled: false,  
    ghost: false,  
    loading: false,  
    shape: 'rectangle',  
    size: 'medium',  
    type: 'default',  
})   

const handleClick = () => {  
    emit('click')  
    // #ifdef APP-HARMONY  
    // 鸿蒙平台特有的逻辑  
    console.log('Running on HarmonyOS!');  
    // #endif  
    // #ifdef MP-WEIXIN  
    // 微信小程序特有的逻辑  
    console.log('Running on WeChat!');  
    // #endif  
}  
</script>

通过简洁的#ifdef预处理指令,我能够轻松为不同平台(鸿蒙、微信小程序、iOS、Android等)编写差异化代码,同时保持核心业务逻辑的一致性。这种"一次开发,多端部署"的能力,让LimeUI组件库的开发效率得到了质的飞跃。

第二章:LimeUI 开发实战 —— 鸿蒙组件库的"简易模式" 🛠️

空谈理论不如实战演练。下面,我将以 LimeUI 中一个简单按钮组件的开发流程为例,带你体验这份"有手就行"的简单与高效。

步骤 1:环境搭建 —— 极简配置,快速上手 ⚡

环境配置非常简单直观,无需担心复杂的鸿蒙原生开发环境问题。按照官方教程完成几个基本步骤即可快速上手:运行和发行教程

步骤 2:组件开发 —— 用 Vue 的方式写鸿蒙组件 🎨

只需在创建 uni_modules 组件时选择相应的组件类型:

生成的uni_modules 组件目录结构如下:

├─pages  
│  └─index  
│     └─index.uvue  
└─uni_modules  
│  └─lime-button  
│     │─components  
│     │  └─lime-button  
│     │      └─lime-button.uvue  // 组件实现  
│     │      └─type.ts       // 类型定义

接下来,让我们开始开发 lime-button 组件:

<!-- lime-button.uvue -->  
<template>  
    <view class="l-button" @click="handleClick">  
      <text>{{ text }}</text>  
    </view>  
</template>  
<script setup lang="uts">  
  import { ButtonProps } from './type';  
  const emit = defineEmits(['click'])  
  const props = withDefaults(defineProps<ButtonProps>(), {  
    block: false,  
    disabled: false,  
    ghost: false,  
    loading: false,  
    shape: 'rectangle',  
    size: 'medium',  
    type: 'default',  
    hoverStopPropagation: false,  
    hoverStartTime: 20,  
    hoverStayTime: 70,  
    lang: 'en',  
    sessionFrom: '',  
    sendMessageTitle: '',  
    sendMessagePath: '',  
    sendMessageImg: '',  
    appParameter: '',  
    showMessageCard: false  
})   

const handleClick = () => {  
    emit('click')  
}  
</script>

看到了吗?这完全就是标准的Vue单文件组件!没有任何鸿蒙原生的特定语法。你已掌握的Vue知识,就是开发鸿蒙组件的全部技能储备。这种熟悉感,让开发者能够立即进入高效开发状态。

步骤 3:编译与预览 —— 所见即所得 👀

在HBuilderX中,只需在manifest.json中配置好鸿蒙应用信息,然后点击菜单栏的"运行 > 运行到手机或模拟器 > 运行到鸿蒙"。

接下来,UniApp(X) 编译器会自动将你编写的 .(u)vue 文件,编译转换为标准的鸿蒙原生工程和 ArkTS 代码。你无需关心底层复杂的转换过程,只需静待编译完成,就能在模拟器上看到组件完美运行。

这正是"有手就行"的最佳诠释。你只需用熟悉的语法表达业务逻辑,UniApp(X)则默默处理好所有平台适配的复杂工作。在将整个UI组件库适配到鸿蒙平台的过程中,我几乎没有遇到实质性的技术障碍,这也是UniApp(X)最大的魅力所在。

第三章:进阶挑战与解决方案 —— 当需要调用原生能力时 💪

当然,"有手就行"并不意味着毫无挑战。在开发LimeUI组件库的过程中,我也遇到过需要调用鸿蒙平台特有能力的场景。

挑战场景:我需要开发一个功能强大的lime-svg组件,它不仅要支持颜色修改,还要兼容多种加载方式(路径、base64、XML)。🎨

解决方案:UniApp(X)贴心地提供了native-view机制,让我们能够轻松调用鸿蒙原生能力。只需在创建uni_modules组件时选择"创建uts插件-标准组件":🔌

生成的uni_modules 组件目录结构如下:

├─pages  
│  └─index  
│     └─index.uvue  
└─uni_modules  
│  └─lime-svg  
│     │─components  
│     │  └─lime-svg  
│     │      └─lime-svg.uvue // 组件调用层  
│     └─utssdk  
│         └─app-harmony  
│             └─builder.ets  // 原生组件实现  
│             └─index.uts    // 桥接类导出

在组件调用层 lime-svg.uvue 中,我们这样编写:

<template>  
    <native-view class="l-svg" v-bind="$attrs" @init="onviewinit"></native-view>  
</template>  
<script setup lang="uts">  

import { SvpProps } from './type'  
import { NativeSvg } from "@/uni_modules/lime-svg"; // 导入桥接类  

let nativeSvg : NativeSvg | null = null  
const props = withDefaults(defineProps<SvpProps>(), {  
    src: '',  
    color: ''  
})  

const onviewinit = (e : UniNativeViewInitEvent) => {  
    nativeSvg = new NativeSvg(e.detail.element); // 传入native-view元素  
    nativeSvg?.updateSrc(props.src) // 调用实例方法更新资源  
    nativeSvg?.updateColor(props.color) // 调用实例方法更新颜色  
}  
</script>

在桥接类 index.uts 中,我们实现与原生能力的对接:

import { BuilderNode } from "@kit.ArkUI"  
import buffer from '@ohos.buffer';  
import { fileIo } from '@kit.CoreFileKit';  
// 导入混编实现的声明式UI构建函数  
import { buildSvg } from "./builder.ets"  
import { getEnv } from '@dcloudio/uni-runtime';  

export class NativeSvg {  
    private $element : UniNativeViewElement;  
    private builder : BuilderNode<[NativeSvgOptions]> | null = null  
    private svgMap : Map<string, string> = new Map<string, string>()  
    // 初始化 buildSvg 默认参数  
    private params : NativeSvgOptions = {  
        src: '',  
        onError: (message) => {  
            this.$element.dispatchEvent(new UniNativeViewEvent("error", { message }))  
        },  
        onComplete: (event : ESObject) => {  
            this.$element.dispatchEvent(new UniNativeViewEvent("load", {  
                width: event.width,  
                height: event.height  
            }))  
        },  
    }  

    constructor(element : UniNativeViewElement) {  
        // 绑定 wrapBuilder 函数  
        this.builder = element.bindHarmonyWrappedBuilder(wrapBuilder<[NativeSvgOptions]>(buildSvg), this.params)  
        this.$element = element  
        // 绑定当前实例为自定义的controller,方便其他地方通过 element 获取使用  
        this.$element.bindHarmonyController(this)  
    }  

    updateSrc(src : string) {  
        if (src.startsWith('data:image') || src.startsWith('<svg')) {  
            if (this.svgMap.has(src)) {  
                this.params.src = this.svgMap.get(src)!  
            } else {  
                // 处理临时文件路径  
                const tempFileName = `${Date.now()}.svg`  
                const tempDirPath = `${getEnv().TEMP_PATH}/svg`  
                const tempFilePath : string = `${tempDirPath}/${tempFileName}`  

                // 确保目录存在  
                if (!fileIo.accessSync(tempDirPath)) {  
                    fileIo.mkdirSync(tempDirPath, true)  
                }  

                // 创建并写入文件  
                const file = fileIo.openSync(tempFilePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE);  
                // 根据不同格式保存SVG内容  
                if (src.startsWith('<svg')) {  
                    fileIo.writeSync(file.fd, src); // 直接写入XML文本  
                }  
                // 获取资源文件的原生路径  
                const path = UTSHarmony.getResourcePath(tempFilePath)  
                this.svgMap.set(src, path) // 缓存已处理的资源  
                this.params.src = path  
            }  
        }  
        else {  
            // 处理普通资源路径  
            this.params.src = UTSHarmony.getResourcePath(src)  
        }  
        this.builder?.update(this.params) // 更新渲染  
    }  

    updateColor(color : string) {  
        this.params.color = color  
        this.builder?.update(this.params) // 更新渲染  
    }  
}

看到代码中我导入了一些原生库,有小伙伴可能会好奇这些库是如何知道的。实际上,通过查阅华为开发者文档,搜索"如何创建临时文件"等相关问题,华为的智能小助手就能直接提供相关代码参考。我们可以基于这些参考代码,根据实际需求进行适当修改和调整,轻松实现临时文件创建等功能。


(cv大师就是我)

最后,在原生渲染层 builder.ets 中,我们定义实际的渲染逻辑:

@Builder  
export function buildSvg(params: ESObject) {  
    Image(params.src)  
        .width('100%')  
        .height('100%')  
        .objectFit(ImageFit.Contain)  
        .fillColor(params.color) // 支持动态修改颜色  
        .onComplete((event)=>{  
            params.onComplete(event)  
        })  
        .onError((error) =>{  
            params.onError(error.message)  
        })  
}

通过这种方式,即使是需要调用鸿蒙特定功能,也能通过UniAppX的UTS标准组件机制轻松实现。这种优雅的桥接设计,让我们既能享受Vue开发的便捷,又能在需要原生能力的关键地方获得与原生开发完全一致的能力。而这只是UniApp(X)调用鸿蒙生态能力的一种方式,在下一章中,我们将探索如何通过UTS API灵活调用OpenHarmony三方库中心仓的第三方库和鸿蒙系统自带的原生库,实现更灵活的功能扩展。

第四章:进阶实战 —— 用UTS API调用OpenHarmony三方库中心仓的第三方库 🚀

如果说组件开发是"有手就行",那么直接调用OpenHarmony生态库就是"如虎添翼"。UniApp(X)提供的UTS(Uni TypeScript)能力,不但能调用鸿蒙系统自带的原生能力,还能加载(ohpm,类似npm的包管理平台)的第三方库,让我们可以在页面中轻松访问各种系统API和三方库功能,为LimeUI组件库的功能扩展提供了无限可能。

案例背景:lime-crypto加密库开发 🔐
在开发过程中,我需要一个强大的加密功能库。虽然在传统UniApp中可以使用crypto-js库,但在UniAppX中并不支持。因此,我决定通过UTS机制直接调用发布到OpenHarmony三方库中心仓的@ohos/crypto-js第三方库来实现加密功能。

实现步骤:简单三步走 📋
只需在创建uni_modules组件时选择"创建uts插件-API插件":

生成的uni_modules组件目录结构如下,与lime-svg类似,它也遵循了UniApp(X)的插件规范:

├─pages  
│  └─index  
│     └─index.uvue  
└─uni_modules  
│  └─lime-crypto  
│     └─utssdk  
│         └─app-harmony  
│             └─config.json // 原生依赖配置  
│             └─index.uts   // UTS桥接层实现

第一步:配置原生依赖 📦
在lime-crypto/utssdk/app-harmony/config.json中声明依赖:

{  
    "dependencies": {  
        "@ohos/crypto-js": "2.0.4"  
    }  
}

就是这么简单!UniApp(X)会自动处理依赖管理。
第二步:编写 UTS 桥接层 🌉
在lime-crypto/utssdk/app-harmony/index.uts中:

import { CryptoJS } from '@ohos/crypto-js'  
export class CryptoImpl {  
    constructor() {  
        // 初始化逻辑  
    }  

    // 获取编码器  
    get enc() {  
        return {  
            Utf8: CryptoJS.enc.Utf8,  
            Hex: CryptoJS.enc.Hex,  
            Base64: CryptoJS.enc.Base64,  
            // ... 其他编码器  
        }   
    }  

    // 加密算法  
    get AES() : CryptoJS.CipherHelper {  
        return CryptoJS.AES  
    }  

    get DES(): CryptoJS.CipherHelper {  
        return CryptoJS.DES  
    }  

    // 哈希函数  
    MD5(message: string) {  
        return CryptoJS.MD5(message)  
    }  

    SHA256(message: string) {  
        return CryptoJS.SHA256(message)  
    }  

    // HMAC 签名  
    HmacSHA256(message: string, secretKey: string) {  
        return CryptoJS.HmacSHA256(message, secretKey)  
    }  
}  

export function useCrypto() {  
    return new CryptoImpl()  
}

有细心的小伙伴可能会发现,为何我要封装一个CryptoImpl类,直接导出CryptoJS不香吗?实际上,虽然直接导出CryptoJS是可行的,但封装一个专门的实现类可以更好地控制API暴露范围,提供更符合业务需求的接口,并为后续可能的功能扩展和维护提供便利(借口,凑字数而已)。

export function useCrypto() {  
    return CryptoJS //全网最简单的加密库实现(搬运工的日常)  
}

第三步:在Vue组件中使用 🎯
现在,我们可以在普通的 Vue 组件中直接使用这个原生加密库了:

<template>  
    <view class="demo-container">  
        <lime-button @click="encryptData">加密测试</lime-button>  
        <text>{{ encryptedText }}</text>  
    </view>  
</template>  

<script setup lang="uts">  
import { useCrypto } from '@/uni_modules/lime-crypto'  

const crypto = useCrypto()  
const encryptedText = ref('')  

const encryptData = () => {  
    // 使用鸿蒙原生加密库进行 AES 加密  
    const encrypted = crypto.AES.encrypt(  
        'Hello HarmonyOS',   
        'secret-key-12345',   
        {   
            mode: crypto.mode.CBC,  
            padding: crypto.pad.Pkcs7   
        }  
    )  

    encryptedText.value = encrypted.toString()  
    console.log('加密结果:', encryptedText.value)  

    // 使用 SHA256 哈希  
    const hash = crypto.SHA256('需要哈希的数据')  
    console.log('SHA256 结果:', hash.toString())  
}  
</script>

通过lime-crypto的实现,我们可以看到UniApp(X)的UTS能力真正实现了:"用前端熟悉的语法,调用原生API的便利"。就这?就这么简单!我上我也行了!成为鸿蒙开发大佬,从此走向人生的鼎峰!

开发者既能利用Vue框架的开发便捷性快速构建UI界面,又能在需要原生能力的关键地方直接调用鸿蒙原生API。这种灵活的技术架构,为鸿蒙应用开发提供了高效且强大的解决方案。

不过,当前的开发体验仍有一些可以改进的空间:ArkTS引擎在代码修改后需要重新构建、签名和安装,这增加了开发过程中的等待时间;另外,首次编译所需的时间相对较长。希望未来的版本能够优化这些方面,进一步提升开发效率。

结语:鸿蒙开发,触手可及 🌈

通过LimeUI组件库的开发实践,我深刻体会到:UniApp(X)极大地降低了鸿蒙应用开发的技术门槛。作为开发者,你完全可以利用已掌握的Vue技术栈,以熟悉的开发方式快速进入鸿蒙开发领域。

鸿蒙生态正处于高速发展阶段,对于开发者而言,这是一片充满机遇的蓝海。UniApp(X)作为连接Vue技术栈与鸿蒙生态的桥梁,为开发者提供了一条低门槛、高效率的技术路径。

对于正在观望鸿蒙开发的开发者来说,现在正是借助UniApp(X)进入鸿蒙生态的理想时机。带着你熟悉的技术积累,你会发现鸿蒙开发并非想象中那样困难,而是可以通过现有技能平稳过渡的技术领域。

毕竟,能用熟悉的技术拥抱未来,这本身就是一件超酷的事,不是吗?💻✨

正是借助UniApp(X)的强大生态与开发便利性,我开发的LimeUI组件库中每个组件都作为独立插件上传到UniApp市场,目前已成功开源发布超过100款组件插件,为开发者社区贡献自己的一份力量!🚀

LimeUI组件库还同时支持UniApp和UniAppX双框架,让开发者可以在两个技术栈中无缝使用同一套组件,极大地提升了开发效率和代码复用性!

如果您在使用过程中发现组件库有任何不完善或缺少的组件,请随时在插件市场留言您的需求和建议。每一条反馈都是促使LimeUI不断完善的宝贵动力,我会认真对待并持续优化组件库,为开发者提供更好的使用体验!

如果您觉得我写的内容对您有所帮助,欢迎点赞加关注,一键三连支持!我第一次写文章,如果有不足之处,还请各位轻喷,我会不断学习和进步!

欢迎访问我的插件市场主页:https://ext.dcloud.net.cn/publisher?id=242774

继续阅读 »

引言:鸿蒙开发,真的有那么难吗?🤔

当鸿蒙系统(HarmonyOS)带着"分布式"、"原生智能"等前沿概念亮相时,许多开发者,尤其是前端开发者,心中难免产生畏惧:学习全新的 ArkTS 语言、掌握 DevEco Studio IDE、理解复杂的系统架构……这些障碍,似乎构筑了一道难以逾越的高墙。

然而,这种复杂的印象可能只是一种误解。如果你已经熟悉 Vue.js,那么惊喜来了——你与鸿蒙应用开发之间的距离,其实只隔了一个 UniApp(X)。本文将结合我开发 LimeUI 组件库的实战经历,向你证明:借助 UniApp(X),鸿蒙应用开发真的可以"有手就行",让前端开发者轻松拥抱鸿蒙生态。

第一章:UniApp(X) —— 鸿蒙开发的高效解决方案 🚀

在着手为鸿蒙生态贡献组件库时,作为一名熟悉UniApp的开发者,我自然选择了UniApp(X)。正是因为它会编译为ArkTS,能保持与原生相同的性能,同时极大地降低了开发门槛:

1. 零门槛的语法亲和性 ✨

对于Vue开发者而言,UniApp(X)的语法几乎是零学习成本的。你可以继续使用熟悉的template-script-style结构,继续运用v-model、v-if等指令。这种无缝衔接的体验,让鸿蒙开发的"陌生感"瞬间烟消云散。

2. 智能的平台差异抹平机制 🔄

UniApp(X) 的核心魅力在于其强大的条件编译系统。在开发 LimeUI 组件库时,我的代码结构通常是这样的:

<template>  
    <view class="l-button" @click="handleClick">  
      <text>{{ text }}</text>  
    </view>  
</template>  
<script setup lang="uts">  
  import { ButtonProps } from './type';  
  const emit = defineEmits(['click'])  
  const props = withDefaults(defineProps<ButtonProps>(), {  
    disabled: false,  
    ghost: false,  
    loading: false,  
    shape: 'rectangle',  
    size: 'medium',  
    type: 'default',  
})   

const handleClick = () => {  
    emit('click')  
    // #ifdef APP-HARMONY  
    // 鸿蒙平台特有的逻辑  
    console.log('Running on HarmonyOS!');  
    // #endif  
    // #ifdef MP-WEIXIN  
    // 微信小程序特有的逻辑  
    console.log('Running on WeChat!');  
    // #endif  
}  
</script>

通过简洁的#ifdef预处理指令,我能够轻松为不同平台(鸿蒙、微信小程序、iOS、Android等)编写差异化代码,同时保持核心业务逻辑的一致性。这种"一次开发,多端部署"的能力,让LimeUI组件库的开发效率得到了质的飞跃。

第二章:LimeUI 开发实战 —— 鸿蒙组件库的"简易模式" 🛠️

空谈理论不如实战演练。下面,我将以 LimeUI 中一个简单按钮组件的开发流程为例,带你体验这份"有手就行"的简单与高效。

步骤 1:环境搭建 —— 极简配置,快速上手 ⚡

环境配置非常简单直观,无需担心复杂的鸿蒙原生开发环境问题。按照官方教程完成几个基本步骤即可快速上手:运行和发行教程

步骤 2:组件开发 —— 用 Vue 的方式写鸿蒙组件 🎨

只需在创建 uni_modules 组件时选择相应的组件类型:

生成的uni_modules 组件目录结构如下:

├─pages  
│  └─index  
│     └─index.uvue  
└─uni_modules  
│  └─lime-button  
│     │─components  
│     │  └─lime-button  
│     │      └─lime-button.uvue  // 组件实现  
│     │      └─type.ts       // 类型定义

接下来,让我们开始开发 lime-button 组件:

<!-- lime-button.uvue -->  
<template>  
    <view class="l-button" @click="handleClick">  
      <text>{{ text }}</text>  
    </view>  
</template>  
<script setup lang="uts">  
  import { ButtonProps } from './type';  
  const emit = defineEmits(['click'])  
  const props = withDefaults(defineProps<ButtonProps>(), {  
    block: false,  
    disabled: false,  
    ghost: false,  
    loading: false,  
    shape: 'rectangle',  
    size: 'medium',  
    type: 'default',  
    hoverStopPropagation: false,  
    hoverStartTime: 20,  
    hoverStayTime: 70,  
    lang: 'en',  
    sessionFrom: '',  
    sendMessageTitle: '',  
    sendMessagePath: '',  
    sendMessageImg: '',  
    appParameter: '',  
    showMessageCard: false  
})   

const handleClick = () => {  
    emit('click')  
}  
</script>

看到了吗?这完全就是标准的Vue单文件组件!没有任何鸿蒙原生的特定语法。你已掌握的Vue知识,就是开发鸿蒙组件的全部技能储备。这种熟悉感,让开发者能够立即进入高效开发状态。

步骤 3:编译与预览 —— 所见即所得 👀

在HBuilderX中,只需在manifest.json中配置好鸿蒙应用信息,然后点击菜单栏的"运行 > 运行到手机或模拟器 > 运行到鸿蒙"。

接下来,UniApp(X) 编译器会自动将你编写的 .(u)vue 文件,编译转换为标准的鸿蒙原生工程和 ArkTS 代码。你无需关心底层复杂的转换过程,只需静待编译完成,就能在模拟器上看到组件完美运行。

这正是"有手就行"的最佳诠释。你只需用熟悉的语法表达业务逻辑,UniApp(X)则默默处理好所有平台适配的复杂工作。在将整个UI组件库适配到鸿蒙平台的过程中,我几乎没有遇到实质性的技术障碍,这也是UniApp(X)最大的魅力所在。

第三章:进阶挑战与解决方案 —— 当需要调用原生能力时 💪

当然,"有手就行"并不意味着毫无挑战。在开发LimeUI组件库的过程中,我也遇到过需要调用鸿蒙平台特有能力的场景。

挑战场景:我需要开发一个功能强大的lime-svg组件,它不仅要支持颜色修改,还要兼容多种加载方式(路径、base64、XML)。🎨

解决方案:UniApp(X)贴心地提供了native-view机制,让我们能够轻松调用鸿蒙原生能力。只需在创建uni_modules组件时选择"创建uts插件-标准组件":🔌

生成的uni_modules 组件目录结构如下:

├─pages  
│  └─index  
│     └─index.uvue  
└─uni_modules  
│  └─lime-svg  
│     │─components  
│     │  └─lime-svg  
│     │      └─lime-svg.uvue // 组件调用层  
│     └─utssdk  
│         └─app-harmony  
│             └─builder.ets  // 原生组件实现  
│             └─index.uts    // 桥接类导出

在组件调用层 lime-svg.uvue 中,我们这样编写:

<template>  
    <native-view class="l-svg" v-bind="$attrs" @init="onviewinit"></native-view>  
</template>  
<script setup lang="uts">  

import { SvpProps } from './type'  
import { NativeSvg } from "@/uni_modules/lime-svg"; // 导入桥接类  

let nativeSvg : NativeSvg | null = null  
const props = withDefaults(defineProps<SvpProps>(), {  
    src: '',  
    color: ''  
})  

const onviewinit = (e : UniNativeViewInitEvent) => {  
    nativeSvg = new NativeSvg(e.detail.element); // 传入native-view元素  
    nativeSvg?.updateSrc(props.src) // 调用实例方法更新资源  
    nativeSvg?.updateColor(props.color) // 调用实例方法更新颜色  
}  
</script>

在桥接类 index.uts 中,我们实现与原生能力的对接:

import { BuilderNode } from "@kit.ArkUI"  
import buffer from '@ohos.buffer';  
import { fileIo } from '@kit.CoreFileKit';  
// 导入混编实现的声明式UI构建函数  
import { buildSvg } from "./builder.ets"  
import { getEnv } from '@dcloudio/uni-runtime';  

export class NativeSvg {  
    private $element : UniNativeViewElement;  
    private builder : BuilderNode<[NativeSvgOptions]> | null = null  
    private svgMap : Map<string, string> = new Map<string, string>()  
    // 初始化 buildSvg 默认参数  
    private params : NativeSvgOptions = {  
        src: '',  
        onError: (message) => {  
            this.$element.dispatchEvent(new UniNativeViewEvent("error", { message }))  
        },  
        onComplete: (event : ESObject) => {  
            this.$element.dispatchEvent(new UniNativeViewEvent("load", {  
                width: event.width,  
                height: event.height  
            }))  
        },  
    }  

    constructor(element : UniNativeViewElement) {  
        // 绑定 wrapBuilder 函数  
        this.builder = element.bindHarmonyWrappedBuilder(wrapBuilder<[NativeSvgOptions]>(buildSvg), this.params)  
        this.$element = element  
        // 绑定当前实例为自定义的controller,方便其他地方通过 element 获取使用  
        this.$element.bindHarmonyController(this)  
    }  

    updateSrc(src : string) {  
        if (src.startsWith('data:image') || src.startsWith('<svg')) {  
            if (this.svgMap.has(src)) {  
                this.params.src = this.svgMap.get(src)!  
            } else {  
                // 处理临时文件路径  
                const tempFileName = `${Date.now()}.svg`  
                const tempDirPath = `${getEnv().TEMP_PATH}/svg`  
                const tempFilePath : string = `${tempDirPath}/${tempFileName}`  

                // 确保目录存在  
                if (!fileIo.accessSync(tempDirPath)) {  
                    fileIo.mkdirSync(tempDirPath, true)  
                }  

                // 创建并写入文件  
                const file = fileIo.openSync(tempFilePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE);  
                // 根据不同格式保存SVG内容  
                if (src.startsWith('<svg')) {  
                    fileIo.writeSync(file.fd, src); // 直接写入XML文本  
                }  
                // 获取资源文件的原生路径  
                const path = UTSHarmony.getResourcePath(tempFilePath)  
                this.svgMap.set(src, path) // 缓存已处理的资源  
                this.params.src = path  
            }  
        }  
        else {  
            // 处理普通资源路径  
            this.params.src = UTSHarmony.getResourcePath(src)  
        }  
        this.builder?.update(this.params) // 更新渲染  
    }  

    updateColor(color : string) {  
        this.params.color = color  
        this.builder?.update(this.params) // 更新渲染  
    }  
}

看到代码中我导入了一些原生库,有小伙伴可能会好奇这些库是如何知道的。实际上,通过查阅华为开发者文档,搜索"如何创建临时文件"等相关问题,华为的智能小助手就能直接提供相关代码参考。我们可以基于这些参考代码,根据实际需求进行适当修改和调整,轻松实现临时文件创建等功能。


(cv大师就是我)

最后,在原生渲染层 builder.ets 中,我们定义实际的渲染逻辑:

@Builder  
export function buildSvg(params: ESObject) {  
    Image(params.src)  
        .width('100%')  
        .height('100%')  
        .objectFit(ImageFit.Contain)  
        .fillColor(params.color) // 支持动态修改颜色  
        .onComplete((event)=>{  
            params.onComplete(event)  
        })  
        .onError((error) =>{  
            params.onError(error.message)  
        })  
}

通过这种方式,即使是需要调用鸿蒙特定功能,也能通过UniAppX的UTS标准组件机制轻松实现。这种优雅的桥接设计,让我们既能享受Vue开发的便捷,又能在需要原生能力的关键地方获得与原生开发完全一致的能力。而这只是UniApp(X)调用鸿蒙生态能力的一种方式,在下一章中,我们将探索如何通过UTS API灵活调用OpenHarmony三方库中心仓的第三方库和鸿蒙系统自带的原生库,实现更灵活的功能扩展。

第四章:进阶实战 —— 用UTS API调用OpenHarmony三方库中心仓的第三方库 🚀

如果说组件开发是"有手就行",那么直接调用OpenHarmony生态库就是"如虎添翼"。UniApp(X)提供的UTS(Uni TypeScript)能力,不但能调用鸿蒙系统自带的原生能力,还能加载(ohpm,类似npm的包管理平台)的第三方库,让我们可以在页面中轻松访问各种系统API和三方库功能,为LimeUI组件库的功能扩展提供了无限可能。

案例背景:lime-crypto加密库开发 🔐
在开发过程中,我需要一个强大的加密功能库。虽然在传统UniApp中可以使用crypto-js库,但在UniAppX中并不支持。因此,我决定通过UTS机制直接调用发布到OpenHarmony三方库中心仓的@ohos/crypto-js第三方库来实现加密功能。

实现步骤:简单三步走 📋
只需在创建uni_modules组件时选择"创建uts插件-API插件":

生成的uni_modules组件目录结构如下,与lime-svg类似,它也遵循了UniApp(X)的插件规范:

├─pages  
│  └─index  
│     └─index.uvue  
└─uni_modules  
│  └─lime-crypto  
│     └─utssdk  
│         └─app-harmony  
│             └─config.json // 原生依赖配置  
│             └─index.uts   // UTS桥接层实现

第一步:配置原生依赖 📦
在lime-crypto/utssdk/app-harmony/config.json中声明依赖:

{  
    "dependencies": {  
        "@ohos/crypto-js": "2.0.4"  
    }  
}

就是这么简单!UniApp(X)会自动处理依赖管理。
第二步:编写 UTS 桥接层 🌉
在lime-crypto/utssdk/app-harmony/index.uts中:

import { CryptoJS } from '@ohos/crypto-js'  
export class CryptoImpl {  
    constructor() {  
        // 初始化逻辑  
    }  

    // 获取编码器  
    get enc() {  
        return {  
            Utf8: CryptoJS.enc.Utf8,  
            Hex: CryptoJS.enc.Hex,  
            Base64: CryptoJS.enc.Base64,  
            // ... 其他编码器  
        }   
    }  

    // 加密算法  
    get AES() : CryptoJS.CipherHelper {  
        return CryptoJS.AES  
    }  

    get DES(): CryptoJS.CipherHelper {  
        return CryptoJS.DES  
    }  

    // 哈希函数  
    MD5(message: string) {  
        return CryptoJS.MD5(message)  
    }  

    SHA256(message: string) {  
        return CryptoJS.SHA256(message)  
    }  

    // HMAC 签名  
    HmacSHA256(message: string, secretKey: string) {  
        return CryptoJS.HmacSHA256(message, secretKey)  
    }  
}  

export function useCrypto() {  
    return new CryptoImpl()  
}

有细心的小伙伴可能会发现,为何我要封装一个CryptoImpl类,直接导出CryptoJS不香吗?实际上,虽然直接导出CryptoJS是可行的,但封装一个专门的实现类可以更好地控制API暴露范围,提供更符合业务需求的接口,并为后续可能的功能扩展和维护提供便利(借口,凑字数而已)。

export function useCrypto() {  
    return CryptoJS //全网最简单的加密库实现(搬运工的日常)  
}

第三步:在Vue组件中使用 🎯
现在,我们可以在普通的 Vue 组件中直接使用这个原生加密库了:

<template>  
    <view class="demo-container">  
        <lime-button @click="encryptData">加密测试</lime-button>  
        <text>{{ encryptedText }}</text>  
    </view>  
</template>  

<script setup lang="uts">  
import { useCrypto } from '@/uni_modules/lime-crypto'  

const crypto = useCrypto()  
const encryptedText = ref('')  

const encryptData = () => {  
    // 使用鸿蒙原生加密库进行 AES 加密  
    const encrypted = crypto.AES.encrypt(  
        'Hello HarmonyOS',   
        'secret-key-12345',   
        {   
            mode: crypto.mode.CBC,  
            padding: crypto.pad.Pkcs7   
        }  
    )  

    encryptedText.value = encrypted.toString()  
    console.log('加密结果:', encryptedText.value)  

    // 使用 SHA256 哈希  
    const hash = crypto.SHA256('需要哈希的数据')  
    console.log('SHA256 结果:', hash.toString())  
}  
</script>

通过lime-crypto的实现,我们可以看到UniApp(X)的UTS能力真正实现了:"用前端熟悉的语法,调用原生API的便利"。就这?就这么简单!我上我也行了!成为鸿蒙开发大佬,从此走向人生的鼎峰!

开发者既能利用Vue框架的开发便捷性快速构建UI界面,又能在需要原生能力的关键地方直接调用鸿蒙原生API。这种灵活的技术架构,为鸿蒙应用开发提供了高效且强大的解决方案。

不过,当前的开发体验仍有一些可以改进的空间:ArkTS引擎在代码修改后需要重新构建、签名和安装,这增加了开发过程中的等待时间;另外,首次编译所需的时间相对较长。希望未来的版本能够优化这些方面,进一步提升开发效率。

结语:鸿蒙开发,触手可及 🌈

通过LimeUI组件库的开发实践,我深刻体会到:UniApp(X)极大地降低了鸿蒙应用开发的技术门槛。作为开发者,你完全可以利用已掌握的Vue技术栈,以熟悉的开发方式快速进入鸿蒙开发领域。

鸿蒙生态正处于高速发展阶段,对于开发者而言,这是一片充满机遇的蓝海。UniApp(X)作为连接Vue技术栈与鸿蒙生态的桥梁,为开发者提供了一条低门槛、高效率的技术路径。

对于正在观望鸿蒙开发的开发者来说,现在正是借助UniApp(X)进入鸿蒙生态的理想时机。带着你熟悉的技术积累,你会发现鸿蒙开发并非想象中那样困难,而是可以通过现有技能平稳过渡的技术领域。

毕竟,能用熟悉的技术拥抱未来,这本身就是一件超酷的事,不是吗?💻✨

正是借助UniApp(X)的强大生态与开发便利性,我开发的LimeUI组件库中每个组件都作为独立插件上传到UniApp市场,目前已成功开源发布超过100款组件插件,为开发者社区贡献自己的一份力量!🚀

LimeUI组件库还同时支持UniApp和UniAppX双框架,让开发者可以在两个技术栈中无缝使用同一套组件,极大地提升了开发效率和代码复用性!

如果您在使用过程中发现组件库有任何不完善或缺少的组件,请随时在插件市场留言您的需求和建议。每一条反馈都是促使LimeUI不断完善的宝贵动力,我会认真对待并持续优化组件库,为开发者提供更好的使用体验!

如果您觉得我写的内容对您有所帮助,欢迎点赞加关注,一键三连支持!我第一次写文章,如果有不足之处,还请各位轻喷,我会不断学习和进步!

欢迎访问我的插件市场主页:https://ext.dcloud.net.cn/publisher?id=242774

收起阅读 »

一个人用 uni-app 做鸿蒙日语学习 App 的踩坑之旅

鸿蒙征文

一个人用 uni-app 做鸿蒙日语学习 App 的踩坑之旅

APP名为:「日语词账」

鸿蒙市场地址:日语词账

缘起:为什么要做这个应用

说起来有点巧合。我学日语断断续续好几年了,试过很多背单词的 App,总觉得差点意思——要么功能太简单,要么塞满了广告,要么离线就废了。去年听说鸿蒙系统要独立成生态,我就想,要不自己搞一个?正好练练手,也给自己学日语用。

就这样,"日语词账"这个项目在我的业余时间里慢慢成型了。从去年 10 月开始写第一行代码,到今年 3 月在华为应用市场上架,前后花了大概半年时间。期间踩了不少坑,也学到了很多东西。今天就来聊聊这个过程。

技术选型:为什么选 uni-app

一开始我是纠结的。做鸿蒙应用,到底该用什么技术?

原生开发我是不敢想的,一个人的精力有限,iOS、Android、鸿蒙三套代码,想想就头大。Flutter 我看了看,鸿蒙支持还不够成熟,社区资源也少。React Native 我倒是熟悉,但对鸿蒙的适配好像也不太行。

最后选了 uni-app,主要是看中了它的跨平台能力。DCloud 团队对鸿蒙的支持确实做得不错,而且我本身就用 Vue,上手很快。更重要的是,写一套代码就能同时跑在多个平台上,这对个人开发者来说太友好了。

功能规划:想清楚再动手

一开始我就给自己定了个原则:功能不求多,但要实用。作为一个学日语的人,我太清楚学习 App 需要什么了。

首先是词汇库。我从网上找了各种资源,最后整理出了 12000 多条词汇,从 N5 到 N1 都有。这个过程挺耗时的,光是数据清洗和格式统一就花了两周。但这是基础,必须做好。

然后是语法。日语语法说复杂也复杂,说简单也简单。我收集了 500 多条常用语法点,每条都配上例句和解释。这个主要是方便自己查询用,毕竟学着学着总会忘。

发音练习这块,我本来想直接用 TTS(文字转语音)技术的,后来发现效果不太好。尤其是五十音,机器合成的声音总感觉怪怪的。最后我找了 103 个高质量的 MP3 音频文件,每个假名都是真人录的,虽然增加了包体积,但效果确实好很多。

测验系统是后来加的。我自己学的时候发现,光看不练很容易忘,必须得有个测验功能来巩固。做了个简单的选择题系统,可以选不同的题量,随机出题,答完立马知道对错。这个功能现在我自己用得最多。

鸿蒙适配:第一次真正的挑战

说实话,在做这个项目之前,我从来没碰过鸿蒙开发。网上的资料也不算特别多,心里还是有点虚的。不过好在 uni-app 把很多底层的东西都封装好了,上手倒也不算太难。

语音这个大坑

最头疼的是语音功能。日语学习 App 没有发音功能,那基本就是个废品。但问题是,不同平台的语音 API 差别太大了,鸿蒙又是个新系统,很多东西都不确定。

我最开始想用 Web Speech API,结果在鸿蒙上根本不work。然后又试了华为的 HMS AI TTS,需要配置一堆东西,还得联网下载语音包,体验不太好。最后我找到了鸿蒙的系统原生 TTS 接口,这个算是比较靠谱的,但也不是百分百可用。

后来我就想了个办法:五十音这些基础发音,我全部用预置的 MP3 文件;复杂的单词句子,再用 TTS 合成。这样既保证了基础发音的质量,又不会让包体积太大。而且万一 TTS 不可用,至少基础功能还在。

这个方案看起来简单,实际做起来挺麻烦的。要判断当前是什么平台,要检测 TTS 是否可用,要处理各种异常情况。不过最后效果还不错,至少我自己用的时候没出过什么问题。

证书签名的折磨

鸿蒙应用必须签名才能装,这个证书配置真的是折磨人。我第一次打包的时候,证书路径配错了,一直提示签名失败。改了半天才发现是相对路径和绝对路径的问题。

还有一次更离谱,我在华为开发者平台上创建了应用,拿到了证书,结果打包的时候又说 bundleName 不匹配。原来是我在不同地方填的包名不一致,有的是点号分隔,有的是下划线。统一改成 com.******.JapaneseWords 之后才搞定。

建议大家一定要保留好证书文件,而且要注意有效期。我有一次差点因为证书过期了无法更新版本,幸好提前发现了。

性能优化的必要性

12000 多条词汇,一次性全加载进来,手机直接卡死。这个问题我也是吃了亏才学乖的。

后来改成分页加载,每次只显示 30 条,往下滑的时候再加载更多。这样首屏速度快多了,从原来的 3 秒多降到了不到 1 秒。虽然实现起来多了点代码,但用户体验提升还是很明显的。

还有就是数据缓存。词汇数据是不会变的,没必要每次都重新加载。我加了个简单的缓存机制,第一次加载完就存在本地,下次直接读缓存。这个优化也很有效。

上架的酸甜苦辣

等代码写得差不多了,就该上架了。这个过程说快也快,说慢也慢,主要看运气。

首先要在华为开发者平台注册账号,创建应用。这一步倒是不难,就是填表格,上传图标、截图什么的。图标我自己用 Figma 画的,简简单单一个日语五十音的"あ"字,配上渐变色,看起来还挺专业。

截图是个细节活。我特意选了几个最能展示功能的页面,词汇学习、发音练习、测验系统,每个都截得清清楚楚。还专门买了个模拟器,确保截图尺寸标准,没有状态栏杂乱的信息。

然后就是隐私政策。这个是必须的,哪怕你的 App 压根不收集用户数据。我就用 GitHub Pages 搭了个简单的隐私政策页面,说明应用不收集任何个人信息,所有数据都存在本地。链接填上去就行。

提交审核的时候,心里还是有点紧张的。第一次提交,过了两天被打回来了,说是权限说明不够详细。我赶紧补充了权限使用说明,说明白为什么需要网络权限(在线 TTS)、为什么需要麦克风权限(未来可能加语音识别)。第二次提交,又等了三天,终于通过了!

看到应用状态变成"已上架"的那一刻,说实话挺激动的。虽然只是个小小的学习工具,但毕竟是自己从零到一做出来的。

上架后的意外收获

应用上架一周后,下载量就破百了。虽然不多,但对个人开发者来说已经很满足了。更让我惊喜的是,有用户给我留言,说这个 App 很实用,离线能用特别好,界面也挺干净。

还有人问能不能加词组功能,能不能加听力练习。这些反馈让我意识到,原来真的有人在用我做的东西,而且还有改进的空间。这种感觉挺奇妙的。

当然也有bug反馈。有个用户说测验系统偶尔会重复出题,我查了半天才发现是随机算法的问题。还有人说暗色主题的某些文字看不清,我赶紧改了颜色对比度。

这些反馈虽然有时候挺头疼的,但也让应用越来越好。到现在我还在坚持更新,每个月至少修一两个bug,加一点小功能。

一些经验和感悟

做完这个项目,回头看看,确实学到了不少东西。

关于技术选型,我觉得 uni-app 对个人开发者真的很友好。虽然有人说它性能不如原生,但对于工具类、内容类应用来说完全够用。而且一套代码多端运行,这个优势太明显了。我现在这个 App,不仅跑在鸿蒙上,iOS 和 Android 也没问题,微信小程序版本我也在计划中。

关于鸿蒙开发,其实没有想象中那么难。很多人觉得鸿蒙是新系统,资料少,不敢尝试。但实际上,华为的文档还是挺全的,DCloud 社区也很活跃,遇到问题基本都能找到答案。而且鸿蒙生态还在快速发展,现在入局正是好时机。

关于产品设计,我学到最大的一点就是:功能要简单,体验要好。我一开始想加很多功能,听力、阅读、作文,什么都想做。后来发现根本做不过来,而且用户也不一定需要。不如把核心功能做扎实,让用户真正能用起来。

关于性能优化,这个真的不能忽视。我一开始觉得,12000 条数据也不算多吧,结果直接卡死。后来才知道,前端优化是门学问,分页加载、虚拟列表、懒加载,这些都是必须的。现在应用打开速度快了,用户体验也好了很多。

还有一点,就是要舍得删代码。我写了很多功能,后来发现有些根本没人用,反而让应用变复杂了。后来狠心删了一些,应用反而更简洁了。

给想做鸿蒙应用的朋友一些建议

如果你也想做鸿蒙应用,我的建议是:别想太多,直接开始!

技术方面,如果你会 Vue,uni-app 上手很快。不会也没关系,uni-app 官网教程挺详细的,跟着做一遍基本就能上手了。

工具方面,HBuilderX 是必备的,虽然有点笨重,但鸿蒙开发离不开它。真机调试最好准备一台鸿蒙手机,模拟器有时候不太靠谱。

资源方面,多逛逛 DCloud 社区论坛,很多问题都有前人踩过坑。GitHub 上也有不少开源项目可以参考。遇到问题别憋着,该提 issue 提 issue,该发帖发帖。

心态方面,做个人项目不要给自己太大压力。能做成最好,做不成也当学习了。我这个项目断断续续做了半年,中间也有好几次想放弃,最后还是坚持下来了。

未来的计划

这个应用目前还在持续更新中。短期内,我打算加上语音识别功能,让发音练习可以自动评分。再优化一下 UI,现在看起来还是有点朴素。

长期来看,我想做个听力练习模块,再加一个阅读理解功能。如果用户量上来了,可能还会考虑做个 Web 版,让大家在电脑上也能用。

至于商业化,暂时还没想那么多。我更希望这是一个真正对学习者有帮助的工具,而不是一个赚钱的产品。如果哪天用户量真的起来了,再考虑做个会员功能,加一些高级特性也不迟。

写在最后

从去年 10 月到现在,"日语词账"这个项目陪我度过了好多个周末和深夜。有时候改一个bug改到凌晨两点,有时候为了一个功能想破头。但看到应用慢慢成型,看到有人在用,这种成就感真的无法形容。

做独立开发者不容易,但也很有乐趣。如果你也有什么想法,不妨试试看。鸿蒙生态还在发展,机会很多。uni-app 让跨平台开发变得简单,个人开发者也能做出不错的产品。

最重要的是,开始行动。不要等到一切都准备好了才开始,边做边学,边学边改,慢慢就会越来越好。

我的这个应用已经在 GitHub 开源了,代码、数据都在上面。如果你感兴趣,可以去看看。如果有什么问题,也欢迎交流。

一起加油吧!

写于2025年11月,深圳,一个普通的周末下午

继续阅读 »

一个人用 uni-app 做鸿蒙日语学习 App 的踩坑之旅

APP名为:「日语词账」

鸿蒙市场地址:日语词账

缘起:为什么要做这个应用

说起来有点巧合。我学日语断断续续好几年了,试过很多背单词的 App,总觉得差点意思——要么功能太简单,要么塞满了广告,要么离线就废了。去年听说鸿蒙系统要独立成生态,我就想,要不自己搞一个?正好练练手,也给自己学日语用。

就这样,"日语词账"这个项目在我的业余时间里慢慢成型了。从去年 10 月开始写第一行代码,到今年 3 月在华为应用市场上架,前后花了大概半年时间。期间踩了不少坑,也学到了很多东西。今天就来聊聊这个过程。

技术选型:为什么选 uni-app

一开始我是纠结的。做鸿蒙应用,到底该用什么技术?

原生开发我是不敢想的,一个人的精力有限,iOS、Android、鸿蒙三套代码,想想就头大。Flutter 我看了看,鸿蒙支持还不够成熟,社区资源也少。React Native 我倒是熟悉,但对鸿蒙的适配好像也不太行。

最后选了 uni-app,主要是看中了它的跨平台能力。DCloud 团队对鸿蒙的支持确实做得不错,而且我本身就用 Vue,上手很快。更重要的是,写一套代码就能同时跑在多个平台上,这对个人开发者来说太友好了。

功能规划:想清楚再动手

一开始我就给自己定了个原则:功能不求多,但要实用。作为一个学日语的人,我太清楚学习 App 需要什么了。

首先是词汇库。我从网上找了各种资源,最后整理出了 12000 多条词汇,从 N5 到 N1 都有。这个过程挺耗时的,光是数据清洗和格式统一就花了两周。但这是基础,必须做好。

然后是语法。日语语法说复杂也复杂,说简单也简单。我收集了 500 多条常用语法点,每条都配上例句和解释。这个主要是方便自己查询用,毕竟学着学着总会忘。

发音练习这块,我本来想直接用 TTS(文字转语音)技术的,后来发现效果不太好。尤其是五十音,机器合成的声音总感觉怪怪的。最后我找了 103 个高质量的 MP3 音频文件,每个假名都是真人录的,虽然增加了包体积,但效果确实好很多。

测验系统是后来加的。我自己学的时候发现,光看不练很容易忘,必须得有个测验功能来巩固。做了个简单的选择题系统,可以选不同的题量,随机出题,答完立马知道对错。这个功能现在我自己用得最多。

鸿蒙适配:第一次真正的挑战

说实话,在做这个项目之前,我从来没碰过鸿蒙开发。网上的资料也不算特别多,心里还是有点虚的。不过好在 uni-app 把很多底层的东西都封装好了,上手倒也不算太难。

语音这个大坑

最头疼的是语音功能。日语学习 App 没有发音功能,那基本就是个废品。但问题是,不同平台的语音 API 差别太大了,鸿蒙又是个新系统,很多东西都不确定。

我最开始想用 Web Speech API,结果在鸿蒙上根本不work。然后又试了华为的 HMS AI TTS,需要配置一堆东西,还得联网下载语音包,体验不太好。最后我找到了鸿蒙的系统原生 TTS 接口,这个算是比较靠谱的,但也不是百分百可用。

后来我就想了个办法:五十音这些基础发音,我全部用预置的 MP3 文件;复杂的单词句子,再用 TTS 合成。这样既保证了基础发音的质量,又不会让包体积太大。而且万一 TTS 不可用,至少基础功能还在。

这个方案看起来简单,实际做起来挺麻烦的。要判断当前是什么平台,要检测 TTS 是否可用,要处理各种异常情况。不过最后效果还不错,至少我自己用的时候没出过什么问题。

证书签名的折磨

鸿蒙应用必须签名才能装,这个证书配置真的是折磨人。我第一次打包的时候,证书路径配错了,一直提示签名失败。改了半天才发现是相对路径和绝对路径的问题。

还有一次更离谱,我在华为开发者平台上创建了应用,拿到了证书,结果打包的时候又说 bundleName 不匹配。原来是我在不同地方填的包名不一致,有的是点号分隔,有的是下划线。统一改成 com.******.JapaneseWords 之后才搞定。

建议大家一定要保留好证书文件,而且要注意有效期。我有一次差点因为证书过期了无法更新版本,幸好提前发现了。

性能优化的必要性

12000 多条词汇,一次性全加载进来,手机直接卡死。这个问题我也是吃了亏才学乖的。

后来改成分页加载,每次只显示 30 条,往下滑的时候再加载更多。这样首屏速度快多了,从原来的 3 秒多降到了不到 1 秒。虽然实现起来多了点代码,但用户体验提升还是很明显的。

还有就是数据缓存。词汇数据是不会变的,没必要每次都重新加载。我加了个简单的缓存机制,第一次加载完就存在本地,下次直接读缓存。这个优化也很有效。

上架的酸甜苦辣

等代码写得差不多了,就该上架了。这个过程说快也快,说慢也慢,主要看运气。

首先要在华为开发者平台注册账号,创建应用。这一步倒是不难,就是填表格,上传图标、截图什么的。图标我自己用 Figma 画的,简简单单一个日语五十音的"あ"字,配上渐变色,看起来还挺专业。

截图是个细节活。我特意选了几个最能展示功能的页面,词汇学习、发音练习、测验系统,每个都截得清清楚楚。还专门买了个模拟器,确保截图尺寸标准,没有状态栏杂乱的信息。

然后就是隐私政策。这个是必须的,哪怕你的 App 压根不收集用户数据。我就用 GitHub Pages 搭了个简单的隐私政策页面,说明应用不收集任何个人信息,所有数据都存在本地。链接填上去就行。

提交审核的时候,心里还是有点紧张的。第一次提交,过了两天被打回来了,说是权限说明不够详细。我赶紧补充了权限使用说明,说明白为什么需要网络权限(在线 TTS)、为什么需要麦克风权限(未来可能加语音识别)。第二次提交,又等了三天,终于通过了!

看到应用状态变成"已上架"的那一刻,说实话挺激动的。虽然只是个小小的学习工具,但毕竟是自己从零到一做出来的。

上架后的意外收获

应用上架一周后,下载量就破百了。虽然不多,但对个人开发者来说已经很满足了。更让我惊喜的是,有用户给我留言,说这个 App 很实用,离线能用特别好,界面也挺干净。

还有人问能不能加词组功能,能不能加听力练习。这些反馈让我意识到,原来真的有人在用我做的东西,而且还有改进的空间。这种感觉挺奇妙的。

当然也有bug反馈。有个用户说测验系统偶尔会重复出题,我查了半天才发现是随机算法的问题。还有人说暗色主题的某些文字看不清,我赶紧改了颜色对比度。

这些反馈虽然有时候挺头疼的,但也让应用越来越好。到现在我还在坚持更新,每个月至少修一两个bug,加一点小功能。

一些经验和感悟

做完这个项目,回头看看,确实学到了不少东西。

关于技术选型,我觉得 uni-app 对个人开发者真的很友好。虽然有人说它性能不如原生,但对于工具类、内容类应用来说完全够用。而且一套代码多端运行,这个优势太明显了。我现在这个 App,不仅跑在鸿蒙上,iOS 和 Android 也没问题,微信小程序版本我也在计划中。

关于鸿蒙开发,其实没有想象中那么难。很多人觉得鸿蒙是新系统,资料少,不敢尝试。但实际上,华为的文档还是挺全的,DCloud 社区也很活跃,遇到问题基本都能找到答案。而且鸿蒙生态还在快速发展,现在入局正是好时机。

关于产品设计,我学到最大的一点就是:功能要简单,体验要好。我一开始想加很多功能,听力、阅读、作文,什么都想做。后来发现根本做不过来,而且用户也不一定需要。不如把核心功能做扎实,让用户真正能用起来。

关于性能优化,这个真的不能忽视。我一开始觉得,12000 条数据也不算多吧,结果直接卡死。后来才知道,前端优化是门学问,分页加载、虚拟列表、懒加载,这些都是必须的。现在应用打开速度快了,用户体验也好了很多。

还有一点,就是要舍得删代码。我写了很多功能,后来发现有些根本没人用,反而让应用变复杂了。后来狠心删了一些,应用反而更简洁了。

给想做鸿蒙应用的朋友一些建议

如果你也想做鸿蒙应用,我的建议是:别想太多,直接开始!

技术方面,如果你会 Vue,uni-app 上手很快。不会也没关系,uni-app 官网教程挺详细的,跟着做一遍基本就能上手了。

工具方面,HBuilderX 是必备的,虽然有点笨重,但鸿蒙开发离不开它。真机调试最好准备一台鸿蒙手机,模拟器有时候不太靠谱。

资源方面,多逛逛 DCloud 社区论坛,很多问题都有前人踩过坑。GitHub 上也有不少开源项目可以参考。遇到问题别憋着,该提 issue 提 issue,该发帖发帖。

心态方面,做个人项目不要给自己太大压力。能做成最好,做不成也当学习了。我这个项目断断续续做了半年,中间也有好几次想放弃,最后还是坚持下来了。

未来的计划

这个应用目前还在持续更新中。短期内,我打算加上语音识别功能,让发音练习可以自动评分。再优化一下 UI,现在看起来还是有点朴素。

长期来看,我想做个听力练习模块,再加一个阅读理解功能。如果用户量上来了,可能还会考虑做个 Web 版,让大家在电脑上也能用。

至于商业化,暂时还没想那么多。我更希望这是一个真正对学习者有帮助的工具,而不是一个赚钱的产品。如果哪天用户量真的起来了,再考虑做个会员功能,加一些高级特性也不迟。

写在最后

从去年 10 月到现在,"日语词账"这个项目陪我度过了好多个周末和深夜。有时候改一个bug改到凌晨两点,有时候为了一个功能想破头。但看到应用慢慢成型,看到有人在用,这种成就感真的无法形容。

做独立开发者不容易,但也很有乐趣。如果你也有什么想法,不妨试试看。鸿蒙生态还在发展,机会很多。uni-app 让跨平台开发变得简单,个人开发者也能做出不错的产品。

最重要的是,开始行动。不要等到一切都准备好了才开始,边做边学,边学边改,慢慢就会越来越好。

我的这个应用已经在 GitHub 开源了,代码、数据都在上面。如果你感兴趣,可以去看看。如果有什么问题,也欢迎交流。

一起加油吧!

写于2025年11月,深圳,一个普通的周末下午

收起阅读 »

【鸿蒙征文】 炸裂!我用uni-app三天让旧应用通杀鸿蒙Next+元服务,华为商店已上架!2W奖励金即将到账。

uni-app鸿蒙开发实践 鸿蒙next 鸿蒙征文

就在其他开发者还在为鸿蒙适配焦头烂额时,我的多款uni-app旧应用已经悄然上架华为应用商店。更让人惊喜的是,连鸿蒙元服务都一并搞定!

一、缘起:鸿蒙风暴前的抉择

9月,华为宣布鸿蒙NEXT不再兼容安卓,整个互联网圈炸开了锅。我的心情和大家一样复杂——手头有10+款用uni-app开发的应用,做的最成功的应用已经在微信小程序、安卓和iOS稳定运行了8年多,累积获得用户总量50+万,服务厂商接近200个。为了我这50+万的粉丝,我必须继续披荆斩棘,用最快的速度适配鸿蒙Next,任务的紧急程度已经迫在眉睫,哪怕这50万人只有10个人用鸿蒙Next系统,我也要让他们拥有原生鸿蒙的最佳体验。问题来了,难道我真的要全部重写整个应用?噢,我滴神呀!救救我吧。

哈哈,,,转机来得比想象中更快。

作为uni-app的资深老玩家,当然是时刻追踪着uni-app适配鸿蒙的进度,期间也在不断学习原生鸿蒙ArkTS语言,但是作为Vue重度使用者(本人已使用Vue长达8年),ArkTS的语法的确让人感到繁杂,虽然都能看懂,无非还是前端的那些东西,但是真正写起来,还是不够舒畅。于是,我决定赌一把,继续使用uni-app去完成所有鸿蒙Next的适配,结果令人震惊:第一个应用仅仅只用了3天,最短的甚至不到8小时,就完成了从原有应用到鸿蒙NEXT+元服务的全适配!

二、实战全记录:72小时创造奇迹

第一阶段:环境搭建(2小时)

说实话,最开始我是忐忑的。但整个过程出乎意料的顺畅:

开发工具:

  • HUAWEI DevEco Studio
  • uni-app v3.99+(支持鸿蒙NEXT)
  • 现有项目直接导入

安装工具还是比较快的,就是下载工具和手机端模拟器比较费时间,这就占用了1个小时,接下来就是创建一个新应用,先把工具跑起来,模拟器跑起来。走一个空白的Hello Word的空项目。

如果是window系统,需要开启虚拟化Hyper-V 、window虚拟机监控平台的相关配置,如果出错了也会有对应的提示,不得不说DevEco工具还是非常人性化的。赞一个!

第二阶段:证书申请(30分钟)

这是最关键的也是最简单,如果申请过苹果的证书,就会非常得心应手。
4大证书分别是:

  1. p12:使用华为开发者工具DevEco 即可创建该证书,该证书不区分开发证书和生产证书。
  2. csr:使用华为开发者工具DevEco 即可创建该证书,该证书不区分开发证书和生产证书。
  3. cer:打开AGC平台需要登陆华为账号进行申请。在证书、APPID 和Profile菜单下操作,需要区分开发和生产证书类型。
  4. p7b:打开AGC平台需要登陆华为账号进行申请。该证书是根据应用来申请的。

申请p12和csr证书截图:

申请cer和p7b证书截图:

在HBX里面的完整配置

证书部分到此结束,还是非常简单滴!主要我这边是从注册账户到注册应用,再到注册证书,所以耗时多一点,如果再添加一个新应用的配置,那就是分分钟钟的事情了。

第三阶段:代码适配(核心8小时)

这里可能是大家最关心的部分——到底要改多少代码?

答案是:少得惊人!

我以一个物流类型的应用为案例进行举例,主要修改点包括:

  1. 登录模块:用华为账户替换以前的注册,华为账户和业务账户的绑定。
  2. 权限模块:调用华为的API获取手机号
  3. 账户注销:所有上架的应用都需要支持用户自主取消关联和账户注销。
  4. 分享功能:适配华为分享

一、登录模块主要调整的原因是:华为要求统一使用华为账户进行静默登录,那么如果以前已经有账户了,那就需要进行做关联即可。使用华为提供的code通过后台接口获取到对应的UnionID,OpenID,通过这些就可以换到手机号了,手机号的权限需要在后台开通,这个申请比较慢,说个小技巧提交申请后,立马就挂个工单,这样就审核的特别快了。

其实很多路子和微信小程序很相似,只是需要后端的配合,支持调用华为的API才行。

// 修改前:微信小程序端  

uni.login({  
  provider: 'weixin', //使用微信登录  
  success: function (loginRes) {  
    console.log(loginRes.authResult);  
  }  
});  

// 修改后:支持鸿蒙获取账户 获取后走接口拿手机号  
uni.login({  
  success: function (loginRes) {  
    console.log(loginRes.authResult);  
  }  
});  

二、权限模块的调整主要是为了满足在华为手机上能有拍照权限,坐标权限,这些自行申请,也都是比较简单的。

三、关于账户注销,这个还是要求必须得有的,其实,这个更简单,上架过iOS的都知道,这个是必须的,基本上无需调整,咱们这里其实也就是改成解除和华为账户的关联关系。
这里就不多说了。

四、分享等调用微信相关的,都需要根据鸿蒙的环境改成调用鸿蒙的即可。这个就需要大面积的排查代码了。简单是简单就是多,所以费时间。

// 修改前:通用分享  
uni.share({  
  provider: "weixin",  
  scene: "WXSceneSession",  
  type: 0,  
  title: "分享标题",  
  success: function (res) {  
    console.log("success:" + JSON.stringify(res));  
  }  
});  

// 修改后:鸿蒙分享  
uni.share({  
  provider: "harmony",  
  type: 0,  
  title: "分享标题",  
  success: function (res) {  
    console.log("鸿蒙分享成功:" + JSON.stringify(res));  
  }  
});

工作量统计:

  • 登录模块:2小时
  • 权限调试:2小时
  • 账户注销:1小时
  • 其他权限和证书适配打包APP:3小时
  • 总计:8小时

其实总结一句话:代码大改的地方真的很少,很少。

建议弟兄们,赶紧上手吧!

第四阶段:调试上架(8小时)

真机调试让我眼前一亮。鸿蒙设备的流畅度确实出色,应用启动速度比安卓版本就是快,用的华为navo12 真机运行非常流畅。

上架过程同样顺畅:

  • 华为开发者账号注册:1小时
  • 应用信息填写:1小时
  • 打包提交审核:1小时
  • 审核通过:一天会审核多次(比苹果快太多了!)

三、意外之喜:元服务的惊喜邂逅

在适配过程中,我发现鸿蒙元服务这个概念很有意思。简单来说,它让用户不用下载完整APP就能使用核心功能。

关键发现:uni-app对元服务的支持几乎是开箱即用!

// 在pages.json中配置元服务页面  
{  
  "pages": [  
    {  
      "path": "pages/index/index",  
      "style": {  
        "isEntry": true,  
        "isAtomic": true  // 标记为元服务页面  
      }  
    }  
  ]  
}

我的一个工具类应用,通过元服务实现了:

  • 用户无需安装即可使用核心计算功能
  • 服务卡片直接展示关键信息
  • 转化率提升明显

四、技术对比:uni-app的降维打击

用过uni-app 开发的朋友都知道,他最大的优势就是全终端兼容,安卓+iOS+小程序+鸿蒙,可以说是天下无敌了,一套代码,真的是省心省力,真的是早用早享受。只要你会点前端,懂Vue,就没有任何技术难度。
当然为了让大家更直观地理解,我做了个对比表:

特性 原生鸿蒙开发 uni-app鸿蒙适配
学习成本 需要学习ArkTS等新技术 使用熟悉的vue语法
代码复用 从零开始 90%+代码可直接复用
开发周期 2-4周/应用 1-3天/应用
多端支持 仅鸿蒙 同时支持小程序、iOS、安卓
维护成本 独立代码库 统一代码库

最让我震撼的是: 旧应用的适配工作远比你担心的要少太多了,只要能跑起来,运行起来,就基本上没啥问题。在这里必须感谢一下uni-app 团队的技术支持,我在适配过程中,有专门的钉钉群提供技术服务,这也是我为何能在短短3天时间就能解决所有兼容的关键。在此一并感谢整个团队!

五、避坑指南:实战中遇到的坑

当然,过程并非完全一帆风顺。记录几个可能帮到大家的坑:

  1. 图片路径问题:鸿蒙对绝对路径更敏感,建议使用相对路径
  2. CSS兼容性:部分CSS3特性需要添加鸿蒙前缀
  3. API异步处理:鸿蒙的API调用更强调异步编程
  4. 代码运行不生效:如果感觉代码没问题,热更新不生效,记得重启整个应用,甚至重启HBX,重启模拟器,有时缓存问题不得不说很难受。
// 推荐使用async/await处理鸿蒙API  
async function initHarmonyFeatures() {  
  try {  
    const result = await uni.harmony.someAPI();  
    // 处理结果  
  } catch (error) {  
    console.error('API调用失败:', error);  
  }  
}

六、成果展示:数字说话

截至目前,我的6款应用全部成功上架:

  • 🎯 适配成功率:100%(6/6)
  • 最快适配记录:8小时(旧的成熟应用)
  • 🚀 平均适配时间:2天/应用
  • 💰 成本节约:相比原生开发,节省约85%成本
  • 📈 用户增长:鸿蒙渠道日均新增用户300+

七、给开发者同仁的真诚建议

  1. 立即行动:鸿蒙生态红利期就在眼前
  2. 从简单应用开始:先拿一个功能简单的应用试水
  3. 关注元服务:这是鸿蒙的独特优势,不要错过
  4. 利用uni-app社区:遇到的问题基本都能找到解决方案

结语:一个人的速度,一个时代的变革

有开发者朋友问我:"现在入场是不是太晚了?"

我的回答是:"当你知道的时候,就是最好的时机。"

uni-app + 鸿蒙的组合,让我在技术变革的浪潮中抓住了先机。 从焦虑观望到全面上架,只用了不到一周时间。这种效率,在过去的移动开发史上是从未有过的。

现在,轮到你了。

在这里顺带解释一下奖励金,华为最近的活动,只要上架成功,有活跃用户,或者参加uni-app的活动,都是可以拿到奖励金的。大家加油!

在得瑟下我们的用户量;


【实战资源分享】

欢迎在评论区交流适配经验,我会第一时间回复大家的问题!

继续阅读 »

就在其他开发者还在为鸿蒙适配焦头烂额时,我的多款uni-app旧应用已经悄然上架华为应用商店。更让人惊喜的是,连鸿蒙元服务都一并搞定!

一、缘起:鸿蒙风暴前的抉择

9月,华为宣布鸿蒙NEXT不再兼容安卓,整个互联网圈炸开了锅。我的心情和大家一样复杂——手头有10+款用uni-app开发的应用,做的最成功的应用已经在微信小程序、安卓和iOS稳定运行了8年多,累积获得用户总量50+万,服务厂商接近200个。为了我这50+万的粉丝,我必须继续披荆斩棘,用最快的速度适配鸿蒙Next,任务的紧急程度已经迫在眉睫,哪怕这50万人只有10个人用鸿蒙Next系统,我也要让他们拥有原生鸿蒙的最佳体验。问题来了,难道我真的要全部重写整个应用?噢,我滴神呀!救救我吧。

哈哈,,,转机来得比想象中更快。

作为uni-app的资深老玩家,当然是时刻追踪着uni-app适配鸿蒙的进度,期间也在不断学习原生鸿蒙ArkTS语言,但是作为Vue重度使用者(本人已使用Vue长达8年),ArkTS的语法的确让人感到繁杂,虽然都能看懂,无非还是前端的那些东西,但是真正写起来,还是不够舒畅。于是,我决定赌一把,继续使用uni-app去完成所有鸿蒙Next的适配,结果令人震惊:第一个应用仅仅只用了3天,最短的甚至不到8小时,就完成了从原有应用到鸿蒙NEXT+元服务的全适配!

二、实战全记录:72小时创造奇迹

第一阶段:环境搭建(2小时)

说实话,最开始我是忐忑的。但整个过程出乎意料的顺畅:

开发工具:

  • HUAWEI DevEco Studio
  • uni-app v3.99+(支持鸿蒙NEXT)
  • 现有项目直接导入

安装工具还是比较快的,就是下载工具和手机端模拟器比较费时间,这就占用了1个小时,接下来就是创建一个新应用,先把工具跑起来,模拟器跑起来。走一个空白的Hello Word的空项目。

如果是window系统,需要开启虚拟化Hyper-V 、window虚拟机监控平台的相关配置,如果出错了也会有对应的提示,不得不说DevEco工具还是非常人性化的。赞一个!

第二阶段:证书申请(30分钟)

这是最关键的也是最简单,如果申请过苹果的证书,就会非常得心应手。
4大证书分别是:

  1. p12:使用华为开发者工具DevEco 即可创建该证书,该证书不区分开发证书和生产证书。
  2. csr:使用华为开发者工具DevEco 即可创建该证书,该证书不区分开发证书和生产证书。
  3. cer:打开AGC平台需要登陆华为账号进行申请。在证书、APPID 和Profile菜单下操作,需要区分开发和生产证书类型。
  4. p7b:打开AGC平台需要登陆华为账号进行申请。该证书是根据应用来申请的。

申请p12和csr证书截图:

申请cer和p7b证书截图:

在HBX里面的完整配置

证书部分到此结束,还是非常简单滴!主要我这边是从注册账户到注册应用,再到注册证书,所以耗时多一点,如果再添加一个新应用的配置,那就是分分钟钟的事情了。

第三阶段:代码适配(核心8小时)

这里可能是大家最关心的部分——到底要改多少代码?

答案是:少得惊人!

我以一个物流类型的应用为案例进行举例,主要修改点包括:

  1. 登录模块:用华为账户替换以前的注册,华为账户和业务账户的绑定。
  2. 权限模块:调用华为的API获取手机号
  3. 账户注销:所有上架的应用都需要支持用户自主取消关联和账户注销。
  4. 分享功能:适配华为分享

一、登录模块主要调整的原因是:华为要求统一使用华为账户进行静默登录,那么如果以前已经有账户了,那就需要进行做关联即可。使用华为提供的code通过后台接口获取到对应的UnionID,OpenID,通过这些就可以换到手机号了,手机号的权限需要在后台开通,这个申请比较慢,说个小技巧提交申请后,立马就挂个工单,这样就审核的特别快了。

其实很多路子和微信小程序很相似,只是需要后端的配合,支持调用华为的API才行。

// 修改前:微信小程序端  

uni.login({  
  provider: 'weixin', //使用微信登录  
  success: function (loginRes) {  
    console.log(loginRes.authResult);  
  }  
});  

// 修改后:支持鸿蒙获取账户 获取后走接口拿手机号  
uni.login({  
  success: function (loginRes) {  
    console.log(loginRes.authResult);  
  }  
});  

二、权限模块的调整主要是为了满足在华为手机上能有拍照权限,坐标权限,这些自行申请,也都是比较简单的。

三、关于账户注销,这个还是要求必须得有的,其实,这个更简单,上架过iOS的都知道,这个是必须的,基本上无需调整,咱们这里其实也就是改成解除和华为账户的关联关系。
这里就不多说了。

四、分享等调用微信相关的,都需要根据鸿蒙的环境改成调用鸿蒙的即可。这个就需要大面积的排查代码了。简单是简单就是多,所以费时间。

// 修改前:通用分享  
uni.share({  
  provider: "weixin",  
  scene: "WXSceneSession",  
  type: 0,  
  title: "分享标题",  
  success: function (res) {  
    console.log("success:" + JSON.stringify(res));  
  }  
});  

// 修改后:鸿蒙分享  
uni.share({  
  provider: "harmony",  
  type: 0,  
  title: "分享标题",  
  success: function (res) {  
    console.log("鸿蒙分享成功:" + JSON.stringify(res));  
  }  
});

工作量统计:

  • 登录模块:2小时
  • 权限调试:2小时
  • 账户注销:1小时
  • 其他权限和证书适配打包APP:3小时
  • 总计:8小时

其实总结一句话:代码大改的地方真的很少,很少。

建议弟兄们,赶紧上手吧!

第四阶段:调试上架(8小时)

真机调试让我眼前一亮。鸿蒙设备的流畅度确实出色,应用启动速度比安卓版本就是快,用的华为navo12 真机运行非常流畅。

上架过程同样顺畅:

  • 华为开发者账号注册:1小时
  • 应用信息填写:1小时
  • 打包提交审核:1小时
  • 审核通过:一天会审核多次(比苹果快太多了!)

三、意外之喜:元服务的惊喜邂逅

在适配过程中,我发现鸿蒙元服务这个概念很有意思。简单来说,它让用户不用下载完整APP就能使用核心功能。

关键发现:uni-app对元服务的支持几乎是开箱即用!

// 在pages.json中配置元服务页面  
{  
  "pages": [  
    {  
      "path": "pages/index/index",  
      "style": {  
        "isEntry": true,  
        "isAtomic": true  // 标记为元服务页面  
      }  
    }  
  ]  
}

我的一个工具类应用,通过元服务实现了:

  • 用户无需安装即可使用核心计算功能
  • 服务卡片直接展示关键信息
  • 转化率提升明显

四、技术对比:uni-app的降维打击

用过uni-app 开发的朋友都知道,他最大的优势就是全终端兼容,安卓+iOS+小程序+鸿蒙,可以说是天下无敌了,一套代码,真的是省心省力,真的是早用早享受。只要你会点前端,懂Vue,就没有任何技术难度。
当然为了让大家更直观地理解,我做了个对比表:

特性 原生鸿蒙开发 uni-app鸿蒙适配
学习成本 需要学习ArkTS等新技术 使用熟悉的vue语法
代码复用 从零开始 90%+代码可直接复用
开发周期 2-4周/应用 1-3天/应用
多端支持 仅鸿蒙 同时支持小程序、iOS、安卓
维护成本 独立代码库 统一代码库

最让我震撼的是: 旧应用的适配工作远比你担心的要少太多了,只要能跑起来,运行起来,就基本上没啥问题。在这里必须感谢一下uni-app 团队的技术支持,我在适配过程中,有专门的钉钉群提供技术服务,这也是我为何能在短短3天时间就能解决所有兼容的关键。在此一并感谢整个团队!

五、避坑指南:实战中遇到的坑

当然,过程并非完全一帆风顺。记录几个可能帮到大家的坑:

  1. 图片路径问题:鸿蒙对绝对路径更敏感,建议使用相对路径
  2. CSS兼容性:部分CSS3特性需要添加鸿蒙前缀
  3. API异步处理:鸿蒙的API调用更强调异步编程
  4. 代码运行不生效:如果感觉代码没问题,热更新不生效,记得重启整个应用,甚至重启HBX,重启模拟器,有时缓存问题不得不说很难受。
// 推荐使用async/await处理鸿蒙API  
async function initHarmonyFeatures() {  
  try {  
    const result = await uni.harmony.someAPI();  
    // 处理结果  
  } catch (error) {  
    console.error('API调用失败:', error);  
  }  
}

六、成果展示:数字说话

截至目前,我的6款应用全部成功上架:

  • 🎯 适配成功率:100%(6/6)
  • 最快适配记录:8小时(旧的成熟应用)
  • 🚀 平均适配时间:2天/应用
  • 💰 成本节约:相比原生开发,节省约85%成本
  • 📈 用户增长:鸿蒙渠道日均新增用户300+

七、给开发者同仁的真诚建议

  1. 立即行动:鸿蒙生态红利期就在眼前
  2. 从简单应用开始:先拿一个功能简单的应用试水
  3. 关注元服务:这是鸿蒙的独特优势,不要错过
  4. 利用uni-app社区:遇到的问题基本都能找到解决方案

结语:一个人的速度,一个时代的变革

有开发者朋友问我:"现在入场是不是太晚了?"

我的回答是:"当你知道的时候,就是最好的时机。"

uni-app + 鸿蒙的组合,让我在技术变革的浪潮中抓住了先机。 从焦虑观望到全面上架,只用了不到一周时间。这种效率,在过去的移动开发史上是从未有过的。

现在,轮到你了。

在这里顺带解释一下奖励金,华为最近的活动,只要上架成功,有活跃用户,或者参加uni-app的活动,都是可以拿到奖励金的。大家加油!

在得瑟下我们的用户量;


【实战资源分享】

欢迎在评论区交流适配经验,我会第一时间回复大家的问题!

收起阅读 »