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功能。

继续阅读 »

从零实现 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功能。

收起阅读 »

修复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)

收起阅读 »

用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!

收起阅读 »

【鸿蒙征文】 炸裂!我用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的活动,都是可以拿到奖励金的。大家加油!

在得瑟下我们的用户量;


【实战资源分享】

欢迎在评论区交流适配经验,我会第一时间回复大家的问题!

收起阅读 »

手贱,离线基座打包编译错误MAMapKit

打包失败

离线基座打包
pointer not aligned in '_dbl_lnds_data_TileDataRespMsg_fields'+0x32 ~SDK/Libs/MAMapKit.framework/MAMapKit[arm64]2
老是提示弹框版本问题,我手贱,去下了一个SDK包,然后把工程的SDK换掉了;

然后就报这个错误了

找了半天,还好之前有过记录,不然怎么解决都不知道;

高德的旧版sdk,我放到网盘了
链接:https://pan.quark.cn/s/75bba91c6ec2

只要替换掉,就可以了

继续阅读 »

离线基座打包
pointer not aligned in '_dbl_lnds_data_TileDataRespMsg_fields'+0x32 ~SDK/Libs/MAMapKit.framework/MAMapKit[arm64]2
老是提示弹框版本问题,我手贱,去下了一个SDK包,然后把工程的SDK换掉了;

然后就报这个错误了

找了半天,还好之前有过记录,不然怎么解决都不知道;

高德的旧版sdk,我放到网盘了
链接:https://pan.quark.cn/s/75bba91c6ec2

只要替换掉,就可以了

收起阅读 »

动态创建web-view加载本地html 页面通讯

web_view

我们日常使用web-view最常见的方法是新建一个页面,然后放一个web-view并配置上我们的html页面地址


这里有另外一种动态创建web-view的方法,更加的灵活

  let wvPath = '/hybrid/html/webview.html'  
  let wv= plus.webview.create(  
    wvPath,  
    'map-view',  
    {  
      'uni-app': 'none',  
      top: systemInfo.statusBarHeight,  
      left: 0,  
      width: systemInfo.screenWidth,  
      height: systemInfo.screenHeight - systemInfo.statusBarHeight,  
      background: '#ffffff',  
      // 启用手势返回  
      // popGesture: 'close',  
    },  
    {  
       // 这里携带web-view的额外参数  
       key  
    }  
  )  
// 一定要记得在不需要的时候 关闭掉web-view  
wv.close()

监听动态创建的web-view发送的消息,切记plus.globalEvent.addEventListener('plusMessage', messageEvent)只需要添加一次,不要每次都添加监听

  plus.globalEvent.addEventListener('plusMessage', messageEvent)  

  function messageEvent(e) {  
    // 检查数据结构  
    if (!e.data || !e.data.args || !e.data.args.data || !e.data.args.data.arg) {  
      console.error('接收到的消息格式不正确:', e)  
      return  
    }  

    let info = e.data.args.data.arg  
  }

竟然用到了plusMessage方法,本来直接使用的message监听发现怎么都不生效
很多人的使用习惯,既然我监听了消息,那么在我不需要的时候是需要把这个监听移除掉的,否则不停的监听影响APP性能,于是写了下面的移除方法:

plus.globalEvent.removeEventListener('plusMessage',messagEvent)

但是出乎意料,移除后整个APP的所有事件都失效!点击哪里都没有反应,切记这个方法是不可以移除的!
可以看到plus.globalEvent监听的是全局的plusMessage事件,移除后影响到了全局的事件,导致APP无响应。
大家会考虑到这样不能移除的话,不是一个好的监听消息方案,频繁监听消息各种类型的消息都会监听到,严重影响APP性能。
那么接下来我们考虑更换页面通信的方案。

第二种 消息传输方式 Webview url拦截
plus.globalEvent监听uni.postMessage推送的消息会出现重复推送等问题,建议改为Webview url拦截的方式获取html文件数据。

  // html中跳转自定义url,会被拦截,不会进行跳转    
      let str = encodeURIComponent(JSON.stringify(obj))  
      location.href = `push://?${str}`  

  // 接收webview发送的通知消息  
  mapwv.overrideUrlLoading({ mode: 'reject'},(e) => {  
      let obj = JSON.parse(decodeURIComponent(e.url.split('?')[1]))  

      removeEvent()  
  })

参考文章:
https://www.html5plus.org/doc/zh_cn/webview.html
url拦截更实时,准确率更高,不会重复接收消息,只有App支持,H5 文档参考:https://www.html5plus.org/doc/zh_cn/webview.html#plus.webview.WebviewObject.overrideUrlLoading
https://ask.dcloud.net.cn/article/35083

继续阅读 »

我们日常使用web-view最常见的方法是新建一个页面,然后放一个web-view并配置上我们的html页面地址


这里有另外一种动态创建web-view的方法,更加的灵活

  let wvPath = '/hybrid/html/webview.html'  
  let wv= plus.webview.create(  
    wvPath,  
    'map-view',  
    {  
      'uni-app': 'none',  
      top: systemInfo.statusBarHeight,  
      left: 0,  
      width: systemInfo.screenWidth,  
      height: systemInfo.screenHeight - systemInfo.statusBarHeight,  
      background: '#ffffff',  
      // 启用手势返回  
      // popGesture: 'close',  
    },  
    {  
       // 这里携带web-view的额外参数  
       key  
    }  
  )  
// 一定要记得在不需要的时候 关闭掉web-view  
wv.close()

监听动态创建的web-view发送的消息,切记plus.globalEvent.addEventListener('plusMessage', messageEvent)只需要添加一次,不要每次都添加监听

  plus.globalEvent.addEventListener('plusMessage', messageEvent)  

  function messageEvent(e) {  
    // 检查数据结构  
    if (!e.data || !e.data.args || !e.data.args.data || !e.data.args.data.arg) {  
      console.error('接收到的消息格式不正确:', e)  
      return  
    }  

    let info = e.data.args.data.arg  
  }

竟然用到了plusMessage方法,本来直接使用的message监听发现怎么都不生效
很多人的使用习惯,既然我监听了消息,那么在我不需要的时候是需要把这个监听移除掉的,否则不停的监听影响APP性能,于是写了下面的移除方法:

plus.globalEvent.removeEventListener('plusMessage',messagEvent)

但是出乎意料,移除后整个APP的所有事件都失效!点击哪里都没有反应,切记这个方法是不可以移除的!
可以看到plus.globalEvent监听的是全局的plusMessage事件,移除后影响到了全局的事件,导致APP无响应。
大家会考虑到这样不能移除的话,不是一个好的监听消息方案,频繁监听消息各种类型的消息都会监听到,严重影响APP性能。
那么接下来我们考虑更换页面通信的方案。

第二种 消息传输方式 Webview url拦截
plus.globalEvent监听uni.postMessage推送的消息会出现重复推送等问题,建议改为Webview url拦截的方式获取html文件数据。

  // html中跳转自定义url,会被拦截,不会进行跳转    
      let str = encodeURIComponent(JSON.stringify(obj))  
      location.href = `push://?${str}`  

  // 接收webview发送的通知消息  
  mapwv.overrideUrlLoading({ mode: 'reject'},(e) => {  
      let obj = JSON.parse(decodeURIComponent(e.url.split('?')[1]))  

      removeEvent()  
  })

参考文章:
https://www.html5plus.org/doc/zh_cn/webview.html
url拦截更实时,准确率更高,不会重复接收消息,只有App支持,H5 文档参考:https://www.html5plus.org/doc/zh_cn/webview.html#plus.webview.WebviewObject.overrideUrlLoading
https://ask.dcloud.net.cn/article/35083

收起阅读 »

动态创建web-view加载本地html 页面通讯

web_view

我们日常使用web-view最常见的方法是新建一个页面,然后放一个web-view并配置上我们的html页面地址

这里有另外一种动态创建web-view的方法,更加的灵活

  let wvPath = '/hybrid/html/webview.html'  
  let wv= new plus.webview.create(  
    wvPath,  
    'map-view',  
    {  
      'uni-app': 'none',  
      top: systemInfo.statusBarHeight,  
      left: 0,  
      width: systemInfo.screenWidth,  
      height: systemInfo.screenHeight - systemInfo.statusBarHeight,  
      background: '#ffffff',  
      // 启用手势返回  
      // popGesture: 'close',  
    },  
    {  
       // 这里携带web-view的额外参数  
       key  
    }  
  )  
// 一定要记得再不需要的时候 关闭掉动态创建的we-view  
wv.close()

监听动态创建的web-view发送的消息

  plus.globalEvent.addEventListener('plusMessage', (e) => {    
          console.log("网页消息", e);    
  })

竟然用到了plusMessage方法,本来直接使用的message监听发现怎么都不生效
从 plus.globalEvent看出监听的是全局的plusMessage方法
很多人的使用习惯,既然我监听了消息,那么在我不需要的时候是需要把这个监听移除掉的,否则不停的监听影响APP性能,于是写了下面的移除方法:

plus.globalEvent.removeEventListener('plusMessage',messagEvent)
但是出乎意料,移除后整个APP的所有事件都失效!点击哪里都没有反应,切记这个方法是不可以移除的

第二种 消息传输方式
plus.globalEvent监听uni.postMessage推送的消息会出现重复推送等问题,建议改为Webview url拦截的方式获取html文件数据。
// html中跳转自定义url,会被拦截,不会进行跳转
window.location.href = 'push?params=loading'

// vue页面wv拦截url变更
wv.overrideUrlLoading({mode:'reject'}, e => {
var params = decodeURI(e.url.split('push?params=')[1])
})

url拦截更实时,准确率更高,不会重复接收消息,只有App支持,H5+文档参考:https://www.html5plus.org/doc/zh_cn/webview.html#plus.webview.WebviewObject.overrideUrlLoading

参考文章:
https://www.html5plus.org/doc/zh_cn/webview.html
https://ask.dcloud.net.cn/article/35083

继续阅读 »

我们日常使用web-view最常见的方法是新建一个页面,然后放一个web-view并配置上我们的html页面地址

这里有另外一种动态创建web-view的方法,更加的灵活

  let wvPath = '/hybrid/html/webview.html'  
  let wv= new plus.webview.create(  
    wvPath,  
    'map-view',  
    {  
      'uni-app': 'none',  
      top: systemInfo.statusBarHeight,  
      left: 0,  
      width: systemInfo.screenWidth,  
      height: systemInfo.screenHeight - systemInfo.statusBarHeight,  
      background: '#ffffff',  
      // 启用手势返回  
      // popGesture: 'close',  
    },  
    {  
       // 这里携带web-view的额外参数  
       key  
    }  
  )  
// 一定要记得再不需要的时候 关闭掉动态创建的we-view  
wv.close()

监听动态创建的web-view发送的消息

  plus.globalEvent.addEventListener('plusMessage', (e) => {    
          console.log("网页消息", e);    
  })

竟然用到了plusMessage方法,本来直接使用的message监听发现怎么都不生效
从 plus.globalEvent看出监听的是全局的plusMessage方法
很多人的使用习惯,既然我监听了消息,那么在我不需要的时候是需要把这个监听移除掉的,否则不停的监听影响APP性能,于是写了下面的移除方法:

plus.globalEvent.removeEventListener('plusMessage',messagEvent)
但是出乎意料,移除后整个APP的所有事件都失效!点击哪里都没有反应,切记这个方法是不可以移除的

第二种 消息传输方式
plus.globalEvent监听uni.postMessage推送的消息会出现重复推送等问题,建议改为Webview url拦截的方式获取html文件数据。
// html中跳转自定义url,会被拦截,不会进行跳转
window.location.href = 'push?params=loading'

// vue页面wv拦截url变更
wv.overrideUrlLoading({mode:'reject'}, e => {
var params = decodeURI(e.url.split('push?params=')[1])
})

url拦截更实时,准确率更高,不会重复接收消息,只有App支持,H5+文档参考:https://www.html5plus.org/doc/zh_cn/webview.html#plus.webview.WebviewObject.overrideUrlLoading

参考文章:
https://www.html5plus.org/doc/zh_cn/webview.html
https://ask.dcloud.net.cn/article/35083

收起阅读 »

uni-vue3--专为 UniApp + Vue3 + UnoCSS 打造的开箱即用模板

uni-vue3

专为 UniApp + Vue3 + UnoCSS 打造的 starter template

源码:https://github.com/vue-rookie/uni-vue3

🚀 特性

  • ✅ Vue3 + Composition API
  • ✅ UnoCSS 原子化CSS
  • ✅ TypeScript 支持
  • ✅ Vite 构建工具

📦 快速开始

小程序预览

产品

建议手机模式预览
uni-vue3框架--------------------------------代码:feature/main
uni-vue3模仿抖音---------------------------代码:feature/douyin
uni-vue3模仿小红书------------------------代码:feature/xiaohongshu

🚀 技术栈

  • 核心框架:Vue 3.4
  • 构建工具:Vite 5.0
  • 开发语言:TypeScript 5.0
  • 状态管理:Pinia 2.0
  • 样式方案:UnoCSS
  • 跨端框架:UniApp 3.0

自定义主题样式(超级自由简单的样式配置):

组件样式全部采用 tailwindcss 封装,您无需写任何 令人烦躁的 css 代码。

只需要修改对应 uno.config.ts 配置文件中的 theme 下的的色值号即可,其他无需任何更改

🪝 自定义 Hooks

项目中精心封装了丰富的自定义 Hooks,大幅提升开发效率和代码质量,所有 Hooks 支持 TypeScript 类型推导。

UI 交互类

useModal - 对话框管理

提供统一的弹窗交互解决方案,包括确认框、消息提示、加载状态等。

import { useModal } from "@/hooks"  

// 在组件中使用  
const {  
  showToast,  
  showSuccess,  
  showError,  
  showConfirm,  
  showLoading,  
  hideLoading,  
  showActionSheet,  
} = useModal()  

// 显示确认对话框  
await showConfirm({  
  title: "操作确认",  
  content: "确定要执行此操作吗?",  
})  

// 显示成功提示  
showSuccess("操作成功")  

// 显示加载状态  
const loading = showLoading()  
try {  
  // 执行异步操作  
} finally {  
  loading.hide() // 隐藏加载  
}  

// 显示底部操作菜单  
const selectedIndex = await showActionSheet({  
  itemList: ["选项一", "选项二", "选项三"],  
})

数据处理类

useStorage - 本地存储

增强版本地存储,支持过期时间、类型安全、自动序列化/反序列化、响应式存储。

import { useStorage } from "@/hooks"  

const { setStorage, getStorage, removeStorage, clearStorage, createReactiveStorage } = useStorage()  

// 存储数据,30天过期  
await setStorage("user-info", { id: 1, name: "Admin" }, 30 * 24 * 60 * 60 * 1000)  

// 读取数据  
const userInfo = await getStorage("user-info")  

// 创建响应式存储  
const count = createReactiveStorage("visit-count", 0)  
count.value++ // 自动同步到存储

useRequest - 网络请求

强大的请求管理 Hook,自动处理加载状态、错误处理、请求缓存等。

import { useRequest } from "@/hooks"  

const request = useRequest({  
  baseURL: "https://api.example.com",  
  autoHandleError: true, // 自动处理错误  
  autoLoading: true, // 自动显示加载状态  
})  

// GET 请求  
const { data } = await request.get("/users", { page: 1 })  

// POST 请求  
await request.post("/articles", { title, content })  

// 文件上传  
await request.upload("/upload", {  
  filePath: tempFilePath,  
  name: "file",  
  formData: { type: "avatar" },  
})

useInputLimit - 输入限制

提供各类输入限制函数,轻松实现各种格式检查和输入控制。

import { useInputDataLimit } from "@/hooks/useInputLimit"  

const {  
  limitNumber, // 仅限数字  
  limitLetter, // 仅限字母  
  limitNumberAndLetter, // 仅限数字和字母  
  limitToPositiveTwoDecimals, // 仅限两位小数的正数  
  limitNoChinese, // 不允许中文  
} = useInputDataLimit()  

// 在输入事件中使用  
const handleInput = (e) => {  
  const rawValue = e.detail.value  
  const formattedValue = limitToPositiveTwoDecimals(rawValue)  
  // 更新表单值  
}

设备能力类

useLocation - 位置服务

封装位置获取、地址解析、坐标转换等功能,支持高精度定位和后台定位。

import { useLocation } from "@/hooks"  

const { getLocation, startLocationUpdate, stopLocationUpdate, chooseLocation, openLocation } =  
  useLocation()  

// 获取当前位置  
const position = await getLocation({  
  type: "gcj02", // 坐标系类型  
  isHighAccuracy: true, // 高精度定位  
})  

// 打开地图选择位置  
const location = await chooseLocation()  

// 在地图上查看位置  
await openLocation({  
  latitude: 39.9087,  
  longitude: 116.3975,  
  name: "目的地",  
  address: "详细地址信息",  
  scale: 18,  
})

useCamera - 相机功能

相机相关功能封装,包括拍照、录像、选择相册等功能。

import { useCamera } from "@/hooks"  

const { takePhoto, chooseImage, chooseVideo, previewImage, compressImage } = useCamera()  

// 拍照或从相册选择  
const filePath = await takePhoto({  
  sourceType: ["camera", "album"],  
})  

// 选择多张图片  
const images = await chooseImage({  
  count: 9,  
  sizeType: ["original", "compressed"],  
})  

// 压缩图片  
const compressedPath = await compressImage(filePath, {  
  quality: 80, // 压缩质量  
})

useSystem - 系统信息

获取系统信息、设备信息、网络状态等功能。

import { useSystem } from "@/hooks"  

const { getSystemInfo, getNetworkType, onNetworkStatusChange, getDeviceInfo, vibrateShort } =  
  useSystem()  

// 获取系统信息  
const systemInfo = getSystemInfo()  
console.log(`运行平台: ${systemInfo.platform}, 系统: ${systemInfo.system}`)  

// 获取网络状态  
const networkType = await getNetworkType()  

// 监听网络变化  
onNetworkStatusChange((res) => {  
  console.log(`网络变更: ${res.networkType}, 是否连接: ${res.isConnected}`)  
})

其他实用 Hooks

useShare - 分享功能

统一的分享接口,支持小程序分享、系统分享、自定义分享等。

import { useShare } from "@/hooks"  

const { share, shareWithSystem, configMiniProgramShare } = useShare()  

// 配置页面分享参数  
configMiniProgramShare({  
  title: "分享标题",  
  path: "/pages/index/index",  
  imageUrl: "/static/share.png",  
  onShareSuccess: () => {  
    console.log("分享成功")  
  },  
})  

// 系统分享  
shareWithSystem({  
  title: "分享内容",  
  summary: "内容摘要",  
  href: "https://example.com",  
  imageUrl: "/static/share.png",  
})

useValidation - 表单验证

强大的表单验证系统,内置多种常用验证规则,支持自定义验证。

import { useValidation } from "@/hooks"  

const validation = useValidation()  

// 创建表单数据和规则  
const { formData, rules, validate, errors, resetValidation } = validation.createForm(  
  {  
    username: "",  
    password: "",  
    email: "",  
    phone: "",  
  },  
  {  
    username: [  
      { required: true, message: "用户名不能为空" },  
      { min: 3, max: 20, message: "长度在3到20个字符" },  
    ],  
    password: [  
      { required: true, message: "密码不能为空" },  
      { min: 6, message: "密码长度不能小于6位" },  
      { pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/, message: "密码必须包含大小写字母和数字" },  
    ],  
    email: [{ type: "email", message: "请输入正确的邮箱格式" }],  
    phone: [{ pattern: /^1[3-9]\d{9}$/, message: "请输入正确的手机号" }],  
  },  
)  

// 提交表单  
const handleSubmit = async () => {  
  const valid = await validate()  
  if (valid) {  
    // 表单验证通过,提交数据  
    console.log("表单数据:", formData)  
  } else {  
    // 表单验证失败  
    console.log("验证错误:", errors.value)  
  }  
}

usePageScroll - 页面滚动

页面滚动相关功能,包括滚动到指定位置、监听滚动事件等。

import { usePageScroll } from "@/hooks"  

const { scrollTo, scrollToTop, scrollToSelector, onPageScroll, getScrollPosition } = usePageScroll()  

// 滚动到顶部  
scrollToTop()  

// 滚动到指定位置  
scrollTo(0, 200, true) // 带动画效果滚动到 200px 位置  

// 滚动到指定元素  
scrollToSelector(".news-item-5", {  
  offset: -20, // 偏移量  
  duration: 300, // 动画时长  
})  

// 监听页面滚动  
onPageScroll((scrollTop) => {  
  if (scrollTop > 100) {  
    // 显示返回顶部按钮  
  }  
})

📋 环境要求

  • Node.js >= 18.0.0
  • pnpm >= 7.0.0
  • 微信开发者工具(开发小程序时使用)

🛠️ 快速开始

# 安装依赖  
pnpm install  

# 开发环境运行  
pnpm dev:mp-weixin  

# 生产环境构建  
pnpm build:mp-weixin

📱 开发指南

微信小程序开发

  1. 开发环境配置

    • 运行 pnpm dev:mp-weixin 生成开发环境代码
    • 使用微信开发者工具导入 dist/dev/mp-weixin 目录
    • 开启"不校验合法域名"选项(开发环境)
  2. 生产环境发布

    • 执行 pnpm build:mp-weixin 生成生产环境代码
    • 使用微信开发者工具导入 dist/build/mp-weixin 目录
    • 点击"上传"按钮发布小程序

项目规范

  1. 组件开发规范

    • 全局组件统一放置在 uni-module 目录下
    • 遵循 UniApp 的 easycom 组件规范
    • 组件命名采用 PascalCase 命名法
  2. 类型定义规范

    • 业务类型定义统一放在对应页面的 type.ts 文件中
    • 公共类型定义放在 types 目录下
    • 类型命名采用 PascalCase 命名法
  3. Hooks 使用规范

    • 公共 hooks 统一放在 hooks 目录下
    • 业务相关 hooks 放在对应页面目录
    • hooks 命名采用 camelCase 命名法,以 use 开头

🚀 性能优化

构建优化

  1. 代码分割

    • 使用 manualChunks 实现代码分割
    • 第三方依赖独立打包,提高缓存效率
    • 路由组件按需加载
  2. 资源优化

    • 图片资源自动压缩
    • CSS 代码压缩和优化
    • 静态资源 CDN 加速
  3. 编译优化

    • 使用 lightningcss 进行 CSS 处理
    • 配置合理的 assetsInlineLimit
    • 优化 Sass 编译配置

运行时优化

  1. 渲染优化

    • 图片懒加载
    • 虚拟列表
    • 条件渲染优化
  2. 性能监控

    • 页面加载性能监控
    • 组件渲染性能分析
    • 内存使用监控

📦 项目结构

├── src  
│   ├── pages              # 页面文件  
│   ├── pages-sub          # 子页面  
│   ├── hooks              # 自定义hooks  
│   ├── static             # 静态资源  
│   ├── types              # 类型定义  
│   ├── utils              # 工具函数  
│   └── uni-module         # 全局组件  
├── vite.config.ts         # Vite 配置  
└── package.json           # 项目配置

源码

stars
forks
watchers
license

📄 开源协议

本项目采用 MIT 协议开源,详情请查看 LICENSE 文件。

致谢

感谢 DCloud 官方,旗下出品的 uni-appuni-app-xuniClouduni-app 小程序 等多平台、多元化的技术体系。

继续阅读 »

uni-vue3

专为 UniApp + Vue3 + UnoCSS 打造的 starter template

源码:https://github.com/vue-rookie/uni-vue3

🚀 特性

  • ✅ Vue3 + Composition API
  • ✅ UnoCSS 原子化CSS
  • ✅ TypeScript 支持
  • ✅ Vite 构建工具

📦 快速开始

小程序预览

产品

建议手机模式预览
uni-vue3框架--------------------------------代码:feature/main
uni-vue3模仿抖音---------------------------代码:feature/douyin
uni-vue3模仿小红书------------------------代码:feature/xiaohongshu

🚀 技术栈

  • 核心框架:Vue 3.4
  • 构建工具:Vite 5.0
  • 开发语言:TypeScript 5.0
  • 状态管理:Pinia 2.0
  • 样式方案:UnoCSS
  • 跨端框架:UniApp 3.0

自定义主题样式(超级自由简单的样式配置):

组件样式全部采用 tailwindcss 封装,您无需写任何 令人烦躁的 css 代码。

只需要修改对应 uno.config.ts 配置文件中的 theme 下的的色值号即可,其他无需任何更改

🪝 自定义 Hooks

项目中精心封装了丰富的自定义 Hooks,大幅提升开发效率和代码质量,所有 Hooks 支持 TypeScript 类型推导。

UI 交互类

useModal - 对话框管理

提供统一的弹窗交互解决方案,包括确认框、消息提示、加载状态等。

import { useModal } from "@/hooks"  

// 在组件中使用  
const {  
  showToast,  
  showSuccess,  
  showError,  
  showConfirm,  
  showLoading,  
  hideLoading,  
  showActionSheet,  
} = useModal()  

// 显示确认对话框  
await showConfirm({  
  title: "操作确认",  
  content: "确定要执行此操作吗?",  
})  

// 显示成功提示  
showSuccess("操作成功")  

// 显示加载状态  
const loading = showLoading()  
try {  
  // 执行异步操作  
} finally {  
  loading.hide() // 隐藏加载  
}  

// 显示底部操作菜单  
const selectedIndex = await showActionSheet({  
  itemList: ["选项一", "选项二", "选项三"],  
})

数据处理类

useStorage - 本地存储

增强版本地存储,支持过期时间、类型安全、自动序列化/反序列化、响应式存储。

import { useStorage } from "@/hooks"  

const { setStorage, getStorage, removeStorage, clearStorage, createReactiveStorage } = useStorage()  

// 存储数据,30天过期  
await setStorage("user-info", { id: 1, name: "Admin" }, 30 * 24 * 60 * 60 * 1000)  

// 读取数据  
const userInfo = await getStorage("user-info")  

// 创建响应式存储  
const count = createReactiveStorage("visit-count", 0)  
count.value++ // 自动同步到存储

useRequest - 网络请求

强大的请求管理 Hook,自动处理加载状态、错误处理、请求缓存等。

import { useRequest } from "@/hooks"  

const request = useRequest({  
  baseURL: "https://api.example.com",  
  autoHandleError: true, // 自动处理错误  
  autoLoading: true, // 自动显示加载状态  
})  

// GET 请求  
const { data } = await request.get("/users", { page: 1 })  

// POST 请求  
await request.post("/articles", { title, content })  

// 文件上传  
await request.upload("/upload", {  
  filePath: tempFilePath,  
  name: "file",  
  formData: { type: "avatar" },  
})

useInputLimit - 输入限制

提供各类输入限制函数,轻松实现各种格式检查和输入控制。

import { useInputDataLimit } from "@/hooks/useInputLimit"  

const {  
  limitNumber, // 仅限数字  
  limitLetter, // 仅限字母  
  limitNumberAndLetter, // 仅限数字和字母  
  limitToPositiveTwoDecimals, // 仅限两位小数的正数  
  limitNoChinese, // 不允许中文  
} = useInputDataLimit()  

// 在输入事件中使用  
const handleInput = (e) => {  
  const rawValue = e.detail.value  
  const formattedValue = limitToPositiveTwoDecimals(rawValue)  
  // 更新表单值  
}

设备能力类

useLocation - 位置服务

封装位置获取、地址解析、坐标转换等功能,支持高精度定位和后台定位。

import { useLocation } from "@/hooks"  

const { getLocation, startLocationUpdate, stopLocationUpdate, chooseLocation, openLocation } =  
  useLocation()  

// 获取当前位置  
const position = await getLocation({  
  type: "gcj02", // 坐标系类型  
  isHighAccuracy: true, // 高精度定位  
})  

// 打开地图选择位置  
const location = await chooseLocation()  

// 在地图上查看位置  
await openLocation({  
  latitude: 39.9087,  
  longitude: 116.3975,  
  name: "目的地",  
  address: "详细地址信息",  
  scale: 18,  
})

useCamera - 相机功能

相机相关功能封装,包括拍照、录像、选择相册等功能。

import { useCamera } from "@/hooks"  

const { takePhoto, chooseImage, chooseVideo, previewImage, compressImage } = useCamera()  

// 拍照或从相册选择  
const filePath = await takePhoto({  
  sourceType: ["camera", "album"],  
})  

// 选择多张图片  
const images = await chooseImage({  
  count: 9,  
  sizeType: ["original", "compressed"],  
})  

// 压缩图片  
const compressedPath = await compressImage(filePath, {  
  quality: 80, // 压缩质量  
})

useSystem - 系统信息

获取系统信息、设备信息、网络状态等功能。

import { useSystem } from "@/hooks"  

const { getSystemInfo, getNetworkType, onNetworkStatusChange, getDeviceInfo, vibrateShort } =  
  useSystem()  

// 获取系统信息  
const systemInfo = getSystemInfo()  
console.log(`运行平台: ${systemInfo.platform}, 系统: ${systemInfo.system}`)  

// 获取网络状态  
const networkType = await getNetworkType()  

// 监听网络变化  
onNetworkStatusChange((res) => {  
  console.log(`网络变更: ${res.networkType}, 是否连接: ${res.isConnected}`)  
})

其他实用 Hooks

useShare - 分享功能

统一的分享接口,支持小程序分享、系统分享、自定义分享等。

import { useShare } from "@/hooks"  

const { share, shareWithSystem, configMiniProgramShare } = useShare()  

// 配置页面分享参数  
configMiniProgramShare({  
  title: "分享标题",  
  path: "/pages/index/index",  
  imageUrl: "/static/share.png",  
  onShareSuccess: () => {  
    console.log("分享成功")  
  },  
})  

// 系统分享  
shareWithSystem({  
  title: "分享内容",  
  summary: "内容摘要",  
  href: "https://example.com",  
  imageUrl: "/static/share.png",  
})

useValidation - 表单验证

强大的表单验证系统,内置多种常用验证规则,支持自定义验证。

import { useValidation } from "@/hooks"  

const validation = useValidation()  

// 创建表单数据和规则  
const { formData, rules, validate, errors, resetValidation } = validation.createForm(  
  {  
    username: "",  
    password: "",  
    email: "",  
    phone: "",  
  },  
  {  
    username: [  
      { required: true, message: "用户名不能为空" },  
      { min: 3, max: 20, message: "长度在3到20个字符" },  
    ],  
    password: [  
      { required: true, message: "密码不能为空" },  
      { min: 6, message: "密码长度不能小于6位" },  
      { pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/, message: "密码必须包含大小写字母和数字" },  
    ],  
    email: [{ type: "email", message: "请输入正确的邮箱格式" }],  
    phone: [{ pattern: /^1[3-9]\d{9}$/, message: "请输入正确的手机号" }],  
  },  
)  

// 提交表单  
const handleSubmit = async () => {  
  const valid = await validate()  
  if (valid) {  
    // 表单验证通过,提交数据  
    console.log("表单数据:", formData)  
  } else {  
    // 表单验证失败  
    console.log("验证错误:", errors.value)  
  }  
}

usePageScroll - 页面滚动

页面滚动相关功能,包括滚动到指定位置、监听滚动事件等。

import { usePageScroll } from "@/hooks"  

const { scrollTo, scrollToTop, scrollToSelector, onPageScroll, getScrollPosition } = usePageScroll()  

// 滚动到顶部  
scrollToTop()  

// 滚动到指定位置  
scrollTo(0, 200, true) // 带动画效果滚动到 200px 位置  

// 滚动到指定元素  
scrollToSelector(".news-item-5", {  
  offset: -20, // 偏移量  
  duration: 300, // 动画时长  
})  

// 监听页面滚动  
onPageScroll((scrollTop) => {  
  if (scrollTop > 100) {  
    // 显示返回顶部按钮  
  }  
})

📋 环境要求

  • Node.js >= 18.0.0
  • pnpm >= 7.0.0
  • 微信开发者工具(开发小程序时使用)

🛠️ 快速开始

# 安装依赖  
pnpm install  

# 开发环境运行  
pnpm dev:mp-weixin  

# 生产环境构建  
pnpm build:mp-weixin

📱 开发指南

微信小程序开发

  1. 开发环境配置

    • 运行 pnpm dev:mp-weixin 生成开发环境代码
    • 使用微信开发者工具导入 dist/dev/mp-weixin 目录
    • 开启"不校验合法域名"选项(开发环境)
  2. 生产环境发布

    • 执行 pnpm build:mp-weixin 生成生产环境代码
    • 使用微信开发者工具导入 dist/build/mp-weixin 目录
    • 点击"上传"按钮发布小程序

项目规范

  1. 组件开发规范

    • 全局组件统一放置在 uni-module 目录下
    • 遵循 UniApp 的 easycom 组件规范
    • 组件命名采用 PascalCase 命名法
  2. 类型定义规范

    • 业务类型定义统一放在对应页面的 type.ts 文件中
    • 公共类型定义放在 types 目录下
    • 类型命名采用 PascalCase 命名法
  3. Hooks 使用规范

    • 公共 hooks 统一放在 hooks 目录下
    • 业务相关 hooks 放在对应页面目录
    • hooks 命名采用 camelCase 命名法,以 use 开头

🚀 性能优化

构建优化

  1. 代码分割

    • 使用 manualChunks 实现代码分割
    • 第三方依赖独立打包,提高缓存效率
    • 路由组件按需加载
  2. 资源优化

    • 图片资源自动压缩
    • CSS 代码压缩和优化
    • 静态资源 CDN 加速
  3. 编译优化

    • 使用 lightningcss 进行 CSS 处理
    • 配置合理的 assetsInlineLimit
    • 优化 Sass 编译配置

运行时优化

  1. 渲染优化

    • 图片懒加载
    • 虚拟列表
    • 条件渲染优化
  2. 性能监控

    • 页面加载性能监控
    • 组件渲染性能分析
    • 内存使用监控

📦 项目结构

├── src  
│   ├── pages              # 页面文件  
│   ├── pages-sub          # 子页面  
│   ├── hooks              # 自定义hooks  
│   ├── static             # 静态资源  
│   ├── types              # 类型定义  
│   ├── utils              # 工具函数  
│   └── uni-module         # 全局组件  
├── vite.config.ts         # Vite 配置  
└── package.json           # 项目配置

源码

stars
forks
watchers
license

📄 开源协议

本项目采用 MIT 协议开源,详情请查看 LICENSE 文件。

致谢

感谢 DCloud 官方,旗下出品的 uni-appuni-app-xuniClouduni-app 小程序 等多平台、多元化的技术体系。

收起阅读 »

ffmpeg 16KB问题

16KB

最近google市场要求16KB内存对齐的问题,市场上大多数的ffmpeg包都不支持;
我找到了一个,请在android studio中的build.gradle中增加
implementation 'com.moizhassan.ffmpeg:ffmpeg-kit-16kb:6.0.0'
通过google脚本测试,该包已修复16KB问题;

附件是我用ffmpeg写好的音视频处理页面;

继续阅读 »

最近google市场要求16KB内存对齐的问题,市场上大多数的ffmpeg包都不支持;
我找到了一个,请在android studio中的build.gradle中增加
implementation 'com.moizhassan.ffmpeg:ffmpeg-kit-16kb:6.0.0'
通过google脚本测试,该包已修复16KB问题;

附件是我用ffmpeg写好的音视频处理页面;

收起阅读 »

关于使用Map组件截图技术方案

截图 地图 map

看网上好多技术方案都是使用集成原生插件,然后在原生插件里面生成截图,而且uniapp插件库里面的好几个这样的插件都需要收费。
如果大家对图片要求不高的话可以关注一些“天地图“的api,他们有根据坐标生成静态图片的接口,下面是链接地址
http://lbs.tianditu.gov.cn/staticapi/static.html
本人亲测可用

继续阅读 »

看网上好多技术方案都是使用集成原生插件,然后在原生插件里面生成截图,而且uniapp插件库里面的好几个这样的插件都需要收费。
如果大家对图片要求不高的话可以关注一些“天地图“的api,他们有根据坐标生成静态图片的接口,下面是链接地址
http://lbs.tianditu.gov.cn/staticapi/static.html
本人亲测可用

收起阅读 »

【报Bug】uni @touchmove 事件与 下拉刷新事件冲突

touchmove

【报Bug】uni @touchmove 事件与 下拉刷新事件冲突

这么多年的bug了为啥不解决呢, 很影响体验啊。

【报Bug】uni @touchmove 事件与 下拉刷新事件冲突

这么多年的bug了为啥不解决呢, 很影响体验啊。