【鸿蒙征文】从零实现 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缺失的问题。
开发鸿蒙平台插件的关键要点包括:
- 设计统一的跨平台接口,确保API一致性
- 正确导入鸿蒙系统模块,如
@kit.CoreSpeechKit和@kit.BasicServicesKit - 实现核心功能类,处理平台特定的API细节
- 封装事件监听机制,提供良好的开发者体验
- 处理音色映射和状态管理,确保功能完整性
希望本文能为您学习如何开发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缺失的问题。
开发鸿蒙平台插件的关键要点包括:
- 设计统一的跨平台接口,确保API一致性
- 正确导入鸿蒙系统模块,如
@kit.CoreSpeechKit和@kit.BasicServicesKit - 实现核心功能类,处理平台特定的API细节
- 封装事件监听机制,提供良好的开发者体验
- 处理音色映射和状态管理,确保功能完整性
希望本文能为您学习如何开发uni-app鸿蒙插件提供有价值的参考,帮助您掌握UTS开发技术,为uni-app生态贡献更多优质的平台适配插件。
目前,本插件已上传至uni-app插件市场,全面支持uni-app和uni-appx框架,并兼容安卓、鸿蒙和Web三大平台,iOS平台将在稍晚上线。开发者可以直接在项目中集成使用,体验跨平台的TTS功能。
收起阅读 »修复vue2 composition-api 中 computed,app环境无法二次更新的问题
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 -->
注意几个细节:
- 鸿蒙不用写
type="2d" - 鸿蒙用
disable-scroll="true",其他平台用@touchmove.prevent - 两个平台都得有
canvas-id和id
初始化时机很关键
不能在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 };
};
绘制流程
整个绘制流程是这样的:
-
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' }; }; -
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(); // 实时重绘 }; -
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();
}
箭头的原理:
- 先算出线的角度:
Math.atan2(dy, dx) - 箭头两边各偏离30度(Math.PI / 6)
- 用三角函数算出两条边的终点坐标
绘制曲线(虚线)
曲线用来表示跑位,所以用虚线:
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);
};
这个算法的核心思路:
- 把点投影到直线上
- 判断投影点是不是在线段范围内
- 算出点到投影点的距离
然后在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会清空画布
导出图片 - 生成分享海报
这个功能挺实用的,步骤是这样的:
- 先截取战术板:把球场、球员、战术线都画到临时canvas上
- 再画海报:在另一个canvas上画标题、战术板图片、底部信息
- 导出为图片:用
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);
});
});
};
注意destWidth和destHeight,设置成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. 图片清晰度问题
症状:导出的图片很模糊
原因:没设置destWidth和destHeight
解决:设置成实际尺寸的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编译到鸿蒙还是有不少坑的。不过搞定之后还挺有成就感的。
开发建议:
- 先在H5上调试,等逻辑都对了再适配鸿蒙
- 鸿蒙的真机调试很慢,多用
console.log和uni.showToast - 坐标问题一定要用实际设备测试,模拟器不准
- 多保存代码,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 -->
注意几个细节:
- 鸿蒙不用写
type="2d" - 鸿蒙用
disable-scroll="true",其他平台用@touchmove.prevent - 两个平台都得有
canvas-id和id
初始化时机很关键
不能在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 };
};
绘制流程
整个绘制流程是这样的:
-
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' }; }; -
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(); // 实时重绘 }; -
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();
}
箭头的原理:
- 先算出线的角度:
Math.atan2(dy, dx) - 箭头两边各偏离30度(Math.PI / 6)
- 用三角函数算出两条边的终点坐标
绘制曲线(虚线)
曲线用来表示跑位,所以用虚线:
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);
};
这个算法的核心思路:
- 把点投影到直线上
- 判断投影点是不是在线段范围内
- 算出点到投影点的距离
然后在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会清空画布
导出图片 - 生成分享海报
这个功能挺实用的,步骤是这样的:
- 先截取战术板:把球场、球员、战术线都画到临时canvas上
- 再画海报:在另一个canvas上画标题、战术板图片、底部信息
- 导出为图片:用
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);
});
});
};
注意destWidth和destHeight,设置成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. 图片清晰度问题
症状:导出的图片很模糊
原因:没设置destWidth和destHeight
解决:设置成实际尺寸的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编译到鸿蒙还是有不少坑的。不过搞定之后还挺有成就感的。
开发建议:
- 先在H5上调试,等逻辑都对了再适配鸿蒙
- 鸿蒙的真机调试很慢,多用
console.log和uni.showToast - 坐标问题一定要用实际设备测试,模拟器不准
- 多保存代码,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请求库,解决几个问题:
-
统一的请求配置
- baseUrl配置
- 默认请求头
- 超时时间
-
请求拦截
- 自动添加token
- 自动添加版本号
- 添加平台标识
-
响应处理
- 统一的错误处理
- 日志记录
代码结构大概这样:
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)
首页是个瀑布流展示热门表情的页面,核心功能:
-
瀑布流加载
- 首次加载热门表情
- 滚动到底部自动加载更多
-
返回顶部
- 滚动超过500px显示返回顶部按钮
-
用户协议
- 首次打开显示用户协议弹窗
关键代码片段:
// 获取热门表情
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);
});
技巧点:
- 用
loading和hasMore标记来避免重复请求 - 用
isLoadMore参数区分首次加载和追加加载 onReachBottom是uni-app提供的触底钩子
4.2 详情页(pages/detail/detail.vue)
详情页展示单个表情包,可以下载保存,还会推荐相关的表情。
关键功能:
-
接收参数
onLoad((options) => { if (options.item) { emojiData.value = JSON.parse(decodeURIComponent(options.item)) } }) -
下载保存
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' }) } }) } } }) } -
相关推荐
- 调用
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 环境准备
-
安装HBuilderX
- 去DCloud官网下载最新版HBuilderX
- 必须是支持鸿蒙的版本
-
准备证书
- 去华为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 运行项目
- 在HBuilderX中打开项目
- 点击工具栏的"运行"
- 选择"运行到鸿蒙"
- 选择设备
- 等待编译完成,APP会自动安装到设备上
6.4 打包发布
-
配置发布证书
- 在
signingConfigs中配置release证书 - 发布证书要在AGC控制台单独申请
- 在
-
本地打包
- 点击HBuilderX的"发行"菜单
- 选择"鸿蒙本地打包"
- 生成.app文件
-
上架应用市场
- 登录华为应用市场控制台
- 上传.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开发鸿蒙应用的整套流程都走通了。
关键点就这几个:
- uni-app框架 - 跨平台的基础
- harmony-configs - 鸿蒙配置的核心
- 证书签名 - 打包发布的关键
只要搞懂这几个点,你也能把自己的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请求库,解决几个问题:
-
统一的请求配置
- baseUrl配置
- 默认请求头
- 超时时间
-
请求拦截
- 自动添加token
- 自动添加版本号
- 添加平台标识
-
响应处理
- 统一的错误处理
- 日志记录
代码结构大概这样:
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)
首页是个瀑布流展示热门表情的页面,核心功能:
-
瀑布流加载
- 首次加载热门表情
- 滚动到底部自动加载更多
-
返回顶部
- 滚动超过500px显示返回顶部按钮
-
用户协议
- 首次打开显示用户协议弹窗
关键代码片段:
// 获取热门表情
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);
});
技巧点:
- 用
loading和hasMore标记来避免重复请求 - 用
isLoadMore参数区分首次加载和追加加载 onReachBottom是uni-app提供的触底钩子
4.2 详情页(pages/detail/detail.vue)
详情页展示单个表情包,可以下载保存,还会推荐相关的表情。
关键功能:
-
接收参数
onLoad((options) => { if (options.item) { emojiData.value = JSON.parse(decodeURIComponent(options.item)) } }) -
下载保存
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' }) } }) } } }) } -
相关推荐
- 调用
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 环境准备
-
安装HBuilderX
- 去DCloud官网下载最新版HBuilderX
- 必须是支持鸿蒙的版本
-
准备证书
- 去华为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 运行项目
- 在HBuilderX中打开项目
- 点击工具栏的"运行"
- 选择"运行到鸿蒙"
- 选择设备
- 等待编译完成,APP会自动安装到设备上
6.4 打包发布
-
配置发布证书
- 在
signingConfigs中配置release证书 - 发布证书要在AGC控制台单独申请
- 在
-
本地打包
- 点击HBuilderX的"发行"菜单
- 选择"鸿蒙本地打包"
- 生成.app文件
-
上架应用市场
- 登录华为应用市场控制台
- 上传.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开发鸿蒙应用的整套流程都走通了。
关键点就这几个:
- uni-app框架 - 跨平台的基础
- harmony-configs - 鸿蒙配置的核心
- 证书签名 - 打包发布的关键
只要搞懂这几个点,你也能把自己的uni-app项目跑在鸿蒙上。
如果遇到问题,多看看官方文档,或者加入开发者社群交流。鸿蒙生态还在快速发展,遇到坑是正常的,解决问题的过程也是成长的过程。
加油!🚀
项目开源地址
GitHub: https://github.com/zwpro/uniapptohongmeng
欢迎 Star、Fork 和提交 Issue!
收起阅读 »【鸿蒙征文】 炸裂!我用uni-app三天让旧应用通杀鸿蒙Next+元服务,华为商店已上架!2W奖励金即将到账。
就在其他开发者还在为鸿蒙适配焦头烂额时,我的多款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大证书分别是:
- p12:使用华为开发者工具DevEco 即可创建该证书,该证书不区分开发证书和生产证书。
- csr:使用华为开发者工具DevEco 即可创建该证书,该证书不区分开发证书和生产证书。
- cer:打开AGC平台需要登陆华为账号进行申请。在证书、APPID 和Profile菜单下操作,需要区分开发和生产证书类型。
- p7b:打开AGC平台需要登陆华为账号进行申请。该证书是根据应用来申请的。
申请p12和csr证书截图:
申请cer和p7b证书截图:
在HBX里面的完整配置
证书部分到此结束,还是非常简单滴!主要我这边是从注册账户到注册应用,再到注册证书,所以耗时多一点,如果再添加一个新应用的配置,那就是分分钟钟的事情了。
第三阶段:代码适配(核心8小时)
这里可能是大家最关心的部分——到底要改多少代码?
答案是:少得惊人!
我以一个物流类型的应用为案例进行举例,主要修改点包括:
- 登录模块:用华为账户替换以前的注册,华为账户和业务账户的绑定。
- 权限模块:调用华为的API获取手机号
- 账户注销:所有上架的应用都需要支持用户自主取消关联和账户注销。
- 分享功能:适配华为分享
一、登录模块主要调整的原因是:华为要求统一使用华为账户进行静默登录,那么如果以前已经有账户了,那就需要进行做关联即可。使用华为提供的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天时间就能解决所有兼容的关键。在此一并感谢整个团队!
五、避坑指南:实战中遇到的坑
当然,过程并非完全一帆风顺。记录几个可能帮到大家的坑:
- 图片路径问题:鸿蒙对绝对路径更敏感,建议使用相对路径
- CSS兼容性:部分CSS3特性需要添加鸿蒙前缀
- API异步处理:鸿蒙的API调用更强调异步编程
- 代码运行不生效:如果感觉代码没问题,热更新不生效,记得重启整个应用,甚至重启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+
七、给开发者同仁的真诚建议
- 立即行动:鸿蒙生态红利期就在眼前
- 从简单应用开始:先拿一个功能简单的应用试水
- 关注元服务:这是鸿蒙的独特优势,不要错过
- 利用uni-app社区:遇到的问题基本都能找到解决方案
结语:一个人的速度,一个时代的变革
有开发者朋友问我:"现在入场是不是太晚了?"
我的回答是:"当你知道的时候,就是最好的时机。"
uni-app + 鸿蒙的组合,让我在技术变革的浪潮中抓住了先机。 从焦虑观望到全面上架,只用了不到一周时间。这种效率,在过去的移动开发史上是从未有过的。
现在,轮到你了。
在这里顺带解释一下奖励金,华为最近的活动,只要上架成功,有活跃用户,或者参加uni-app的活动,都是可以拿到奖励金的。大家加油!
在得瑟下我们的用户量;
【实战资源分享】
- uni-app 鸿蒙专题:uni-app 鸿蒙适配专题
- 鸿蒙开发者官网:鸿蒙官方技术文档
- 华为ACG 后台:登录
- 华为应用市场:应用商店
欢迎在评论区交流适配经验,我会第一时间回复大家的问题!
就在其他开发者还在为鸿蒙适配焦头烂额时,我的多款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大证书分别是:
- p12:使用华为开发者工具DevEco 即可创建该证书,该证书不区分开发证书和生产证书。
- csr:使用华为开发者工具DevEco 即可创建该证书,该证书不区分开发证书和生产证书。
- cer:打开AGC平台需要登陆华为账号进行申请。在证书、APPID 和Profile菜单下操作,需要区分开发和生产证书类型。
- p7b:打开AGC平台需要登陆华为账号进行申请。该证书是根据应用来申请的。
申请p12和csr证书截图:
申请cer和p7b证书截图:
在HBX里面的完整配置
证书部分到此结束,还是非常简单滴!主要我这边是从注册账户到注册应用,再到注册证书,所以耗时多一点,如果再添加一个新应用的配置,那就是分分钟钟的事情了。
第三阶段:代码适配(核心8小时)
这里可能是大家最关心的部分——到底要改多少代码?
答案是:少得惊人!
我以一个物流类型的应用为案例进行举例,主要修改点包括:
- 登录模块:用华为账户替换以前的注册,华为账户和业务账户的绑定。
- 权限模块:调用华为的API获取手机号
- 账户注销:所有上架的应用都需要支持用户自主取消关联和账户注销。
- 分享功能:适配华为分享
一、登录模块主要调整的原因是:华为要求统一使用华为账户进行静默登录,那么如果以前已经有账户了,那就需要进行做关联即可。使用华为提供的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天时间就能解决所有兼容的关键。在此一并感谢整个团队!
五、避坑指南:实战中遇到的坑
当然,过程并非完全一帆风顺。记录几个可能帮到大家的坑:
- 图片路径问题:鸿蒙对绝对路径更敏感,建议使用相对路径
- CSS兼容性:部分CSS3特性需要添加鸿蒙前缀
- API异步处理:鸿蒙的API调用更强调异步编程
- 代码运行不生效:如果感觉代码没问题,热更新不生效,记得重启整个应用,甚至重启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+
七、给开发者同仁的真诚建议
- 立即行动:鸿蒙生态红利期就在眼前
- 从简单应用开始:先拿一个功能简单的应用试水
- 关注元服务:这是鸿蒙的独特优势,不要错过
- 利用uni-app社区:遇到的问题基本都能找到解决方案
结语:一个人的速度,一个时代的变革
有开发者朋友问我:"现在入场是不是太晚了?"
我的回答是:"当你知道的时候,就是最好的时机。"
uni-app + 鸿蒙的组合,让我在技术变革的浪潮中抓住了先机。 从焦虑观望到全面上架,只用了不到一周时间。这种效率,在过去的移动开发史上是从未有过的。
现在,轮到你了。
在这里顺带解释一下奖励金,华为最近的活动,只要上架成功,有活跃用户,或者参加uni-app的活动,都是可以拿到奖励金的。大家加油!
在得瑟下我们的用户量;
【实战资源分享】
- uni-app 鸿蒙专题:uni-app 鸿蒙适配专题
- 鸿蒙开发者官网:鸿蒙官方技术文档
- 华为ACG 后台:登录
- 华为应用市场:应用商店
欢迎在评论区交流适配经验,我会第一时间回复大家的问题!
收起阅读 »手贱,离线基座打包编译错误MAMapKit
动态创建web-view加载本地html 页面通讯
我们日常使用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并配置上我们的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
📱 开发指南
微信小程序开发
-
开发环境配置
- 运行
pnpm dev:mp-weixin生成开发环境代码 - 使用微信开发者工具导入
dist/dev/mp-weixin目录 - 开启"不校验合法域名"选项(开发环境)
- 运行
-
生产环境发布
- 执行
pnpm build:mp-weixin生成生产环境代码 - 使用微信开发者工具导入
dist/build/mp-weixin目录 - 点击"上传"按钮发布小程序
- 执行
项目规范
-
组件开发规范
- 全局组件统一放置在
uni-module目录下 - 遵循 UniApp 的 easycom 组件规范
- 组件命名采用 PascalCase 命名法
- 全局组件统一放置在
-
类型定义规范
- 业务类型定义统一放在对应页面的
type.ts文件中 - 公共类型定义放在
types目录下 - 类型命名采用 PascalCase 命名法
- 业务类型定义统一放在对应页面的
-
Hooks 使用规范
- 公共 hooks 统一放在
hooks目录下 - 业务相关 hooks 放在对应页面目录
- hooks 命名采用 camelCase 命名法,以
use开头
- 公共 hooks 统一放在
🚀 性能优化
构建优化
-
代码分割
- 使用
manualChunks实现代码分割 - 第三方依赖独立打包,提高缓存效率
- 路由组件按需加载
- 使用
-
资源优化
- 图片资源自动压缩
- CSS 代码压缩和优化
- 静态资源 CDN 加速
-
编译优化
- 使用
lightningcss进行 CSS 处理 - 配置合理的
assetsInlineLimit - 优化 Sass 编译配置
- 使用
运行时优化
-
渲染优化
- 图片懒加载
- 虚拟列表
- 条件渲染优化
-
性能监控
- 页面加载性能监控
- 组件渲染性能分析
- 内存使用监控
📦 项目结构
├── src
│ ├── pages # 页面文件
│ ├── pages-sub # 子页面
│ ├── hooks # 自定义hooks
│ ├── static # 静态资源
│ ├── types # 类型定义
│ ├── utils # 工具函数
│ └── uni-module # 全局组件
├── vite.config.ts # Vite 配置
└── package.json # 项目配置
源码
📄 开源协议
本项目采用 MIT 协议开源,详情请查看 LICENSE 文件。
致谢
感谢 DCloud 官方,旗下出品的 uni-app 、uni-app-x 、uniCloud、uni-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
📱 开发指南
微信小程序开发
-
开发环境配置
- 运行
pnpm dev:mp-weixin生成开发环境代码 - 使用微信开发者工具导入
dist/dev/mp-weixin目录 - 开启"不校验合法域名"选项(开发环境)
- 运行
-
生产环境发布
- 执行
pnpm build:mp-weixin生成生产环境代码 - 使用微信开发者工具导入
dist/build/mp-weixin目录 - 点击"上传"按钮发布小程序
- 执行
项目规范
-
组件开发规范
- 全局组件统一放置在
uni-module目录下 - 遵循 UniApp 的 easycom 组件规范
- 组件命名采用 PascalCase 命名法
- 全局组件统一放置在
-
类型定义规范
- 业务类型定义统一放在对应页面的
type.ts文件中 - 公共类型定义放在
types目录下 - 类型命名采用 PascalCase 命名法
- 业务类型定义统一放在对应页面的
-
Hooks 使用规范
- 公共 hooks 统一放在
hooks目录下 - 业务相关 hooks 放在对应页面目录
- hooks 命名采用 camelCase 命名法,以
use开头
- 公共 hooks 统一放在
🚀 性能优化
构建优化
-
代码分割
- 使用
manualChunks实现代码分割 - 第三方依赖独立打包,提高缓存效率
- 路由组件按需加载
- 使用
-
资源优化
- 图片资源自动压缩
- CSS 代码压缩和优化
- 静态资源 CDN 加速
-
编译优化
- 使用
lightningcss进行 CSS 处理 - 配置合理的
assetsInlineLimit - 优化 Sass 编译配置
- 使用
运行时优化
-
渲染优化
- 图片懒加载
- 虚拟列表
- 条件渲染优化
-
性能监控
- 页面加载性能监控
- 组件渲染性能分析
- 内存使用监控
📦 项目结构
├── src
│ ├── pages # 页面文件
│ ├── pages-sub # 子页面
│ ├── hooks # 自定义hooks
│ ├── static # 静态资源
│ ├── types # 类型定义
│ ├── utils # 工具函数
│ └── uni-module # 全局组件
├── vite.config.ts # Vite 配置
└── package.json # 项目配置
源码
📄 开源协议
本项目采用 MIT 协议开源,详情请查看 LICENSE 文件。
致谢
感谢 DCloud 官方,旗下出品的 uni-app 、uni-app-x 、uniCloud、uni-app 小程序 等多平台、多元化的技术体系。
收起阅读 »ffmpeg 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组件截图技术方案
看网上好多技术方案都是使用集成原生插件,然后在原生插件里面生成截图,而且uniapp插件库里面的好几个这样的插件都需要收费。
如果大家对图片要求不高的话可以关注一些“天地图“的api,他们有根据坐标生成静态图片的接口,下面是链接地址
http://lbs.tianditu.gov.cn/staticapi/static.html
本人亲测可用
看网上好多技术方案都是使用集成原生插件,然后在原生插件里面生成截图,而且uniapp插件库里面的好几个这样的插件都需要收费。
如果大家对图片要求不高的话可以关注一些“天地图“的api,他们有根据坐标生成静态图片的接口,下面是链接地址
http://lbs.tianditu.gov.cn/staticapi/static.html
本人亲测可用
【报Bug】uni @touchmove 事件与 下拉刷新事件冲突
【报Bug】uni @touchmove 事件与 下拉刷新事件冲突
这么多年的bug了为啥不解决呢, 很影响体验啊。
【报Bug】uni @touchmove 事件与 下拉刷新事件冲突
这么多年的bug了为啥不解决呢, 很影响体验啊。




















