• [技术干货] 开发者技术支持-蓝牙设备搜索及连接技术方案总结
    1. 关键技术难点总结1.1 问题说明在蓝牙设备连接场景中,主要面临以下技术难点:设备发现不稳定:蓝牙设备可能因电源管理或信号干扰导致发现不稳定,例如电动自行车通过蓝牙发现时可能出现信号波动配对流程复杂:不同类型的蓝牙设备有着各自的安全验证机制和配对要求,有些设备需要输入密码,有些需要确认配对请求,还有些设备需要特定的配对顺序。用户在连接设备时往往需要经过多个步骤才能完成配对,操作繁琐且容易出错。连接状态管理困难:设备在使用过程中可能出现连接断开或信号不稳定的情况1.2 原因分析硬件兼容性问题:不同品牌设备的蓝牙模块实现可能存在差异,如电动自行车、耳机、手环等设备的蓝牙模块差异连接状态监听:蓝牙连接状态变化需要及时监听和响应,否则会影响用户体验异常处理机制:设备在使用过程中可能遇到各种异常情况,需要完善的异常处理机制2. 解决思路针对蓝牙设备连接场景,采用以下解决思路:分层架构设计:将蓝牙功能分为设备发现、配对管理、连接控制三个层次,每一层专注处理特定任务,降低系统复杂性连接状态跟踪:实时跟踪设备的连接状态变化,确保能及时了解设备是已连接、已断开还是正在连接中异步操作处理:所有蓝牙操作均采用异步方式处理,避免阻塞主线程完善的异常处理:建立完整的异常处理机制,确保在各种异常情况下都能正确处理3. 解决方案3.1 核心代码实现3.1.1 设备发现模块 (DiscoveryDeviceManager.ets)export class DiscoveryDeviceManager { /** * 开始扫描蓝牙设备 * @param callback 扫描结果回调 */ public startDiscovery(callback: Callback<Array<connection.DiscoveryResult>>) { try { // 注册扫描结果监听 connection.on('discoveryResult', callback); } catch (err) { console.error('注册扫描回调失败: ' + (err as BusinessError).code + ', ' + (err as BusinessError).message); } try { // 检查是否正在扫描 let scan = connection.isBluetoothDiscovering(); if (!scan) { // 开始扫描设备 connection.startBluetoothDiscovery(); } } catch (err) { console.error('启动蓝牙扫描失败: ' + (err as BusinessError).code + ', ' + (err as BusinessError).message); } } /** * 停止扫描蓝牙设备 * @param callback 扫描结果回调 */ public stopDiscovery(callback: Callback<Array<connection.DiscoveryResult>>) { try { let scan = connection.isBluetoothDiscovering(); if (scan) { // 停止扫描 connection.stopBluetoothDiscovery(); } // 取消注册扫描结果监听 connection.off('discoveryResult', callback); } catch (err) { console.error('停止蓝牙扫描失败: ' + (err as BusinessError).code + ', ' + (err as BusinessError).message); } } /** * 获取已配对设备 * @returns 已配对设备列表 */ public getPairedDevices() { try { // 获取已配对设备信息 let devices = connection.getPairedDevices(); return devices; } catch (err) { console.error('获取已配对设备失败: ' + (err as BusinessError).code + ', ' + (err as BusinessError).message); return []; } } } 3.1.2 配对管理模块 (PairDeviceManager.ets)export class PairDeviceManager { device: string = ''; pairState: connection.BondState = connection.BondState.BOND_STATE_INVALID; a2dpSrc = a2dp.createA2dpSrcProfile(); hfpAg = hfp.createHfpAgProfile(); hidHost = hid.createHidHostProfile(); /** * 发起配对 * @param device 设备ID * @param callback 配对状态回调 */ public startPair(device: string, callback: Callback<connection.BondStateParam>) { this.device = device; try { // 订阅配对状态变化事件 connection.on('bondStateChange', callback); } catch (err) { console.error('订阅配对状态失败: ' + (err as BusinessError).code + ', ' + (err as BusinessError).message); } try { // 发起配对 connection.pairDevice(device).then(() => { console.info('开始配对设备'); }, (error: BusinessError) => { console.error('配对设备失败: errCode:' + error.code + ',errMessage:' + error.message); }); } catch (err) { console.error('发起配对失败: errCode:' + err.code + ',errMessage:' + err.message); } } /** * 连接设备 * @param device 设备ID */ public async connect(device: string) { try { // 获取设备支持的Profile let uuids = await connection.getRemoteProfileUuids(device); console.info('设备支持的Profile: '+ JSON.stringify(uuids)); let allowedProfiles = 0; // 根据设备支持的Profile注册对应的连接状态监听 if (uuids.some(uuid => uuid == constant.ProfileUuids.PROFILE_UUID_A2DP_SINK.toLowerCase())) { console.info('设备支持A2DP'); allowedProfiles++; this.a2dpSrc.on('connectionStateChange', this.onA2dpConnectStateChange); } if (uuids.some(uuid => uuid == constant.ProfileUuids.PROFILE_UUID_HFP_HF.toLowerCase())) { console.info('设备支持HFP'); allowedProfiles++; this.hfpAg.on('connectionStateChange', this.onHfpConnectStateChange); } if (uuids.some(uuid => uuid == constant.ProfileUuids.PROFILE_UUID_HID.toLowerCase()) || uuids.some(uuid => uuid == constant.ProfileUuids.PROFILE_UUID_HOGP.toLowerCase())) { console.info('设备支持HID'); allowedProfiles++; this.hidHost.on('connectionStateChange', this.onHidConnectStateChange); } // 如果存在可用的Profile,则发起连接 if (allowedProfiles > 0) { connection.connectAllowedProfiles(device).then(() => { console.info('连接设备成功'); }, (error: BusinessError) => { console.error('连接设备失败: errCode:' + error.code + ',errMessage:' + error.message); }); } } catch (err) { console.error('连接设备异常: errCode:' + err.code + ',errMessage:' + err.message); } } // A2DP连接状态变化回调 onA2dpConnectStateChange = (data: baseProfile.StateChangeParam) => { console.info(`A2DP连接状态: ${JSON.stringify(data)}`); }; // HFP连接状态变化回调 onHfpConnectStateChange = (data: baseProfile.StateChangeParam) => { console.info(`HFP连接状态: ${JSON.stringify(data)}`); }; // HID连接状态变化回调 onHidConnectStateChange = (data: baseProfile.StateChangeParam) => { console.info(`HID连接状态: ${JSON.stringify(data)}`); }; } 3.1.3 UI页面实现 (BluetoothDevicePage.ets)@Observed class DeviceInfo { deviceId: string = ''; discoveryResult: connection.DiscoveryResult | null = null; deviceState: connection.BondState | constant.ProfileConnectionState = connection.BondState.BOND_STATE_INVALID } @Entry @Component struct BluetoothDevicePage { @State message: string = '蓝牙设备连接'; @State deviceList: DeviceInfo[] = []; @State isScanning: boolean = false; @State connectedDevice: string = ''; @State deviceId: string = ''; pairState: connection.BondState = connection.BondState.BOND_STATE_INVALID; private context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext; // 扫描结果回调 private onReceiveEvent = (data: Array<connection.DiscoveryResult>) => { console.info('发现蓝牙设备: ' + JSON.stringify(data)); data.forEach(item => { let deviceInfo: DeviceInfo = new DeviceInfo(); deviceInfo.deviceId = item.deviceId; deviceInfo.discoveryResult = item; this.deviceList.push(deviceInfo); }) } // 配对状态变化回调 private onBondStateEvent = (data: connection.BondStateParam) => { console.info('配对结果: '+ JSON.stringify(data)); if (data && data.deviceId == this.deviceId) { this.pairState = data.state; } if (data.state == connection.BondState.BOND_STATE_BONDED) { // 配对成功,显示提示并连接设备 promptAction.showToast({message: '配对成功'}) this.message = '连接成功'; this.connectedDevice = data.deviceId this.setDeviceState(data, connection.BondState.BOND_STATE_BONDED) // 连接已配对设备 pairDeviceManager.connect(data.deviceId); } } private setDeviceState(data: connection.BondStateParam, state: connection.BondState) { // 更新设备状态 this.deviceList = this.deviceList.map((item) => { let temp = new DeviceInfo(); temp.deviceId = item.deviceId.trimEnd() + ' ' temp.discoveryResult = item.discoveryResult; if (item.deviceId == data.deviceId) { temp.deviceState = state; } else { temp.deviceState = item.deviceState; } return temp; }) } private startDiscovery() { if (this.isScanning) { return; } let permissions: string[] = ['ohos.permission.ACCESS_BLUETOOTH']; SysPermissionUtils.request(this.context, (isGranted: boolean, permission: string) => { if (permissions.indexOf(permission) >= 0 && isGranted) { this.isScanning = true; this.message = '正在扫描设备...'; discoveryDeviceManager.startDiscovery(this.onReceiveEvent); // 10秒后自动停止扫描 setTimeout(() => { this.stopDiscovery(); }, 10000); } }, 'ohos.permission.ACCESS_BLUETOOTH'); } private stopDiscovery() { if (!this.isScanning) { return; } this.isScanning = false; this.message = '扫描已停止'; discoveryDeviceManager.stopDiscovery(this.onReceiveEvent); } private connectToDevice(device: connection.DiscoveryResult) { this.message = '正在连接设备...'; this.deviceId = device.deviceId; // 发起配对 pairDeviceManager.startPair(device.deviceId, this.onBondStateEvent); } build() { Column() { Text(this.message) .fontSize(20) .fontWeight(FontWeight.Bold) .margin({ top: 20 }) if (this.connectedDevice) { Text('已连接设备: ' + this.connectedDevice) .fontSize(16) .fontColor('#007DFF') .margin({ top: 10 }) } Row() { Button(this.isScanning ? '停止扫描' : '开始扫描') .onClick(() => { if (this.isScanning) { this.stopDiscovery(); } else { this.startDiscovery(); } }) .width('45%') .height(50) .margin({ right: 10 }) Button('查看已配对设备') .onClick(() => { let pairedDevices = discoveryDeviceManager.getPairedDevices(); if (pairedDevices.length > 0) { this.message = '找到 ' + pairedDevices.length + ' 个已配对设备'; } else { this.message = '没有已配对的设备'; } }) .width('45%') .height(50) } .width('90%') .margin({ top: 20 }) Text('发现的设备列表') .fontSize(18) .fontWeight(FontWeight.Bold) .margin({ top: 30, bottom: 10 }) List() { ForEach(this.deviceList, (item: DeviceInfo) => { ListItem() { Column() { Row() { Text(item.discoveryResult?.deviceName || '未知设备') .fontSize(16) .fontWeight(FontWeight.Bold) Blank() if ( connection.BondState.BOND_STATE_BONDED == item.deviceState ) { Text('已配对') .fontSize(14) .fontColor('#00B51D') .onClick(() => { // 连接已配对设备 if (item.discoveryResult) { pairDeviceManager.connect(item.discoveryResult.deviceId); } }) } else if(constant.ProfileConnectionState.STATE_CONNECTED == item.deviceState) { Text('已连接') .fontSize(14) .fontColor('#00B51D') } else { Button(`连接`) .onClick(() => { if (item.discoveryResult) { this.connectToDevice(item.discoveryResult); } }) .height(30) .fontSize(14) } } .width('100%') Row() { Text('ID: ' + item.deviceId) .fontSize(12) .fontColor('#888888') Blank() Text('信号: ' + item.discoveryResult?.rssi + 'dBm') .fontSize(12) .fontColor('#888888') } .width('100%') .margin({ top: 5 }) } .width('100%') .padding(15) } }, (item: DeviceInfo) => item.deviceId) } .layoutWeight(1) .width('90%') .margin({ bottom: 20 }) } .alignItems(HorizontalAlign.Center) .justifyContent(FlexAlign.Start) .width('100%') .height('100%') } } 4. 方案成果总结本方案通过分层架构设计,实现了稳定可靠的蓝牙设备连接功能。系统能够自动发现附近的蓝牙设备,如电动自行车、耳机、手环等,并支持一键配对和连接。用户可以通过直观的界面轻松管理设备连接状态,享受便捷的无线控制体验。能够及时感知设备连接状态的变化,无论是设备连接成功、断开还是正在连接中,用户都能获得清晰的反馈。这种设计大大提升了用户体验,让用户能够随时掌握设备连接情况。该技术方案具有良好的通用性和扩展性,适用于各种蓝牙设备连接场景。通过完善的异常处理机制和优化的扫描策略,能够有效应对不同设备的连接挑战,为用户提供稳定可靠的蓝牙连接服务,满足日常生活中各种智能设备的连接需求。
  • [开发技术领域专区] 开发者技术支持-录屏功能实现案例技术总结
    1.1问题说明在开发鸿蒙应用的屏幕录制功能时,会遇到不少实际问题,主要集中在这几个方面:一是录屏时要全程处理文件的创建、读写和关闭,要是操作不规范,很容易出现文件资源无法正常释放,或者录制内容写不进文件的问题;二是录屏功能对手机系统资源消耗较大,如果功能启动后没有及时关闭,会导致手机卡顿、耗电快,影响应用和系统的正常运行;三是录屏时存在隐私泄露风险,需要准确避开包含隐私信息的窗口,还要能灵活应对进入或退出隐私场景的情况;四是录屏过程中可能出现多种意外状况,比如用户主动停止录屏、来了电话、麦克风用不了、切换账号等,要是没有全面的监控机制,就会出现功能失控或出问题后没有提示的情况;1.2原因分析(一)文件问题根源:录屏内容需持续保存到本地文件,若未按“打开-使用-关闭”流程操作(如中断后未关文件),会导致资源占用;另外,在鸿蒙系统的安全文件管理规则下,如果写错了文件保存路径,会直接导致文件创建失败,录屏也没法继续。(二)资源占用根源:鸿蒙录屏组件资源消耗大,需通过系统接口启停。如果只启动了录屏功能,结束后却没有执行关闭操作,会导致手机的音频、视频处理资源一直被占用,进而引发应用卡顿或和其他功能冲突的问题。(三)隐私安全根源:应用的部分窗口会显示用户敏感信息,比如输入密码的界面、私人数据展示页,要是录屏功能默认录制所有窗口,就会泄露隐私;(四)状态监控根源:录屏功能会受到用户操作、系统提示、硬件状态等多种因素影响,可能出现的情况比较复杂。如果没有做好全面的监控设置,就没法及时应对各种变化;要是缺少问题处理机制,出现麦克风权限被拒绝这类问题时,开发人员和用户都没法及时知道原因。(五)设置匹配根源:录屏参数需适配手机硬件与文件格式。清晰度过高或过低会影响效果,音视频参数不兼容会导致无声音,格式与后缀不匹配则视频无法播放。1.3解决思路(一)规范文件操作:按照鸿蒙系统的文件管理规则,获取应用专属的安全文件路径,建立“创建文件-保存内容-关闭文件”的完整流程,确保录屏从开始到结束,文件操作都有章可循,避免资源浪费。(二)做好功能启停管理:明确录屏功能组件的启动、初始化、使用和关闭时机,不管是正常结束录屏还是出现意外情况,都要及时关闭功能组件,释放手机的音频、视频处理等系统资源。(三)构建隐私保护机制:通过指定隐私窗口的标识,精准跳过这些窗口的录制;同时监控手机是否进入或退出隐私场景,做到进入隐私场景时暂停录制敏感内容,退出后再恢复,避免隐私泄露。(四)完善监控和问题处理:设置录屏状态变化和问题提示的监控机制,覆盖用户操作、系统事件、硬件故障等各种情况,确保不管出现什么状态都能及时发现,遇到问题也能快速定位原因。(五)优化参数设置:结合大多数手机的性能,设置合适的默认参数,比如视频清晰度、声音采样质量等,同时让视频文件格式和后缀保持一致,确保录制的视频能正常播放,兼容性更好。1.4解决方案借助鸿蒙系统的媒体处理和文件管理工具,开发了一个专门的录屏管理功能模块,把文件操作、参数设置、状态监控、录屏启停等核心功能整合在一起,实现了功能的模块化设计,方便后续复用和调整。组件代码示例:// 导入鸿蒙系统媒体库与文件管理核心工具 import { media } from '@kit.MediaKit'; import { fileIo as fs } from '@kit.CoreFileKit'; import { Context } from '@kit.AbilityKit'; /** * 鸿蒙应用录屏管理类 * 整合文件操作、参数配置、状态监控、隐私保护等核心能力 */ export class HarmonyScreenCaptureManager { // 录屏核心实例 private captureRecorder?: media.AVScreenCaptureRecorder; // 录屏文件句柄 private captureFile: fs.File | null = null; // 录屏配置参数 private captureConfig?: media.AVScreenCaptureRecordConfig; // 隐私窗口ID列表(可根据实际需求修改) private privacyWindowIds: number[] = [57, 86]; // 录屏状态标识 private isCapturing: boolean = false; // 类销毁时自动清理资源 public destroy(): void { if (this.isCapturing) { this.stopCapture().catch(err => console.error('销毁时停止录屏失败:', err)); } } /** * 1. 规范创建录屏文件(沙箱路径,避免文件路径错误) * @param context 应用上下文 * @returns 是否创建成功 */ private async createCaptureFile(context: Context): Promise<boolean> { try { // 鸿蒙应用沙箱路径:确保文件操作符合系统安全规则 const capturePath = `${context.filesDir}/screen_record_${Date.now()}.mp4`; // 读写+创建模式打开文件,获取文件句柄 this.captureFile = fs.openSync(capturePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE); console.info(`录屏文件创建成功,路径:${capturePath}`); return true; } catch (err) { console.error('录屏文件创建失败:', (err as Error).message); this.captureFile = null; return false; } } /** * 2. 初始化录屏配置参数(匹配硬件性能) * 对应技术总结中"优化参数设置"需求 */ private initCaptureConfig(): boolean { if (!this.captureFile) return false; // 核心参数配置:与技术总结中参数保持一致 this.captureConfig = { frameWidth: 768, // 视频宽度 frameHeight: 1280, // 视频高度 fd: this.captureFile.fd, // 关联文件句柄 videoBitrate: 10 * 1024 * 1024, // 视频比特率10Mbps audioSampleRate: 48000, // 音频采样率48kHz audioChannelCount: 2, // 双声道 audioBitrate: 96 * 1024, // 音频比特率96kbps displayId: 0, // 录制主屏幕 // 主流编码格式:确保视频兼容性 preset: media.AVScreenCaptureRecordPreset.SCREEN_RECORD_PRESET_H264_AAC_MP4 }; return true; } /** * 3. 关闭录屏文件(释放文件资源) */ private closeCaptureFile(): void { if (this.captureFile) { try { fs.closeSync(this.captureFile); console.info('录屏文件已安全关闭'); } catch (err) { console.error('关闭录屏文件失败:', (err as Error).message); } finally { this.captureFile = null; } } } /** * 1. 隐私保护:跳过指定隐私窗口录制 * 对应技术总结"构建隐私保护机制"需求 */ private async setPrivacyProtection(): Promise<void> { if (this.captureRecorder && this.privacyWindowIds.length > 0) { try { // 豁免隐私窗口:避免密码页、支付页被录制 await this.captureRecorder.skipPrivacyMode(this.privacyWindowIds); console.info(`已豁免隐私窗口,窗口ID:${this.privacyWindowIds.join(',')}`); } catch (err) { console.error('设置隐私窗口豁免失败:', (err as Error).message); } } } /** * 2. 状态与异常监控(覆盖12种核心场景) * 对应技术总结"完善监控和问题处理"需求 */ private registerCaptureCallbacks(): void { if (!this.captureRecorder) return; // 录屏状态变化监听 this.captureRecorder.on('stateChange', async (state: media.AVScreenCaptureStateCode) => { switch (state) { case media.AVScreenCaptureStateCode.SCREENCAPTURE_STATE_STARTED: this.isCapturing = true; console.info('录屏已成功启动'); break; case media.AVScreenCaptureStateCode.SCREENCAPTURE_STATE_CANCELED: console.info('录屏被系统拒绝(无权限或资源占用)'); await this.stopCapture(); break; case media.AVScreenCaptureStateCode.SCREENCAPTURE_STATE_STOPPED_BY_USER: console.info('用户主动停止录屏'); await this.stopCapture(); break; case media.AVScreenCaptureStateCode.SCREENCAPTURE_STATE_ENTER_PRIVATE_SCENE: console.info('进入隐私场景,暂停录屏'); await this.captureRecorder.pauseRecording(); // 暂停录制敏感内容 break; case media.AVScreenCaptureStateCode.SCREENCAPTURE_STATE_EXIT_PRIVATE_SCENE: console.info('退出隐私场景,恢复录屏'); await this.captureRecorder.resumeRecording(); // 恢复录制 break; case media.AVScreenCaptureStateCode.SCREENCAPTURE_STATE_STOPPED_BY_CALL: console.info('通话中断,停止录屏'); await this.stopCapture(); break; default: console.info(`录屏状态变更:${state}`); break; } }); // 异常监听:快速定位故障原因 this.captureRecorder.on('error', (err: { code: number, message: string }) => { console.error(`录屏异常 - 错误码:${err.code},原因:${err.message}`); this.stopCapture().catch(e => console.error('异常后停止录屏失败:', e)); }); } /** * 3. 取消监听:避免内存泄漏 */ private unregisterCaptureCallbacks(): void { if (this.captureRecorder) { this.captureRecorder.off('stateChange'); this.captureRecorder.off('error'); } } /** * 启动录屏(完整流程:初始化-配置-启动) * @param context 应用上下文 * @returns 启动结果 */ public async startCapture(context: Context): Promise<boolean> { try { // 1. 初始化录屏实例 this.captureRecorder = await media.createAVScreenCaptureRecorder(); if (!this.captureRecorder) { console.error('录屏实例创建失败'); return false; } // 2. 创建录屏文件(核心前置步骤) const fileCreated = await this.createCaptureFile(context); if (!fileCreated) return false; // 3. 初始化录屏配置 const configInited = this.initCaptureConfig(); if (!configInited || !this.captureConfig) { console.error('录屏参数配置失败'); this.closeCaptureFile(); return false; } // 4. 初始化录屏组件 await this.captureRecorder.init(this.captureConfig); // 5. 注册监控与隐私保护 this.registerCaptureCallbacks(); await this.setPrivacyProtection(); // 6. 启动录制 await this.captureRecorder.startRecording(); return true; } catch (err) { console.error('录屏启动失败:', (err as Error).message); // 异常时清理资源 this.stopCapture().catch(e => console.error('启动失败后清理资源出错:', e)); return false; } } /** * 停止录屏(规范释放资源,避免卡顿) * 对应技术总结"做好功能启停管理"需求 */ public async stopCapture(): Promise<void> { if (!this.isCapturing) return; try { // 1. 停止录制核心操作 if (this.captureRecorder) { await this.captureRecorder.stopRecording(); console.info('录屏已停止'); } } finally { // 2. 强制清理资源(无论是否异常都执行) this.unregisterCaptureCallbacks(); if (this.captureRecorder) { await this.captureRecorder.release(); // 释放录屏组件资源 this.captureRecorder = undefined; } this.closeCaptureFile(); // 关闭文件句柄 this.isCapturing = false; } } /** * 获取当前录屏状态 * @returns 是否正在录屏 */ public getCaptureState(): boolean { return this.isCapturing; } } 演示代码示例:// 在Ability或Page中集成使用 import { HarmonyScreenCaptureManager } from './HarmonyScreenCaptureManager'; import { Ability } from '@kit.AbilityKit'; export default class MainAbility extends Ability { // 初始化录屏管理实例 private captureManager: HarmonyScreenCaptureManager = new HarmonyScreenCaptureManager(); // 启动录屏(如按钮点击事件) async startCaptureClick() { const isStarted = await this.captureManager.startCapture(this.context); if (isStarted) { // 提示用户录屏已启动 console.info('用户已启动录屏'); } else { // 提示用户启动失败 console.info('录屏启动失败,请检查权限'); } } // 停止录屏(如按钮点击事件) async stopCaptureClick() { await this.captureManager.stopCapture(); console.info('用户已停止录屏'); } // 应用销毁时清理 onDestroy() { this.captureManager.destroy(); super.onDestroy(); } } 1.5方案成果总结(一) 功能完整性:实现了录屏从启动到停止的全流程管理,支持用户主动控制录屏启停,也能应对来电、账号切换等系统事件导致的录屏停止,还能有效跳过隐私窗口。该功能覆盖了12种常见的录屏状态和问题场景,既能满足基础的录屏需求,也为后续功能扩展留足了空间。(二)稳定性与性能:通过规范的录屏组件使用和释放流程,以及标准的文件操作步骤,避免了资源浪费和文件损坏问题;合理的音视频参数设置降低了手机的处理压力,在大多数鸿蒙手机上都能实现流畅录屏,不会出现明显的卡顿或手机发热现象。(三)隐私与安全:采用“精准跳过隐私窗口+监控隐私场景变化”的双重保护方式,有效避免了隐私窗口和系统隐私场景的内容被误录,符合鸿蒙应用的隐私安全要求,降低了应用因隐私问题违规的风险。(四)可维护性:将录屏功能拆分成多个独立模块,比如文件操作、参数设置、监控管理等,每个模块只负责一项工作,让代码更容易理解;同时完整记录录屏状态和问题信息,方便开发人员排查故障,降低后续维护难度。
  • [技术干货] 开发者技术支持-APP启动引导与操作指引技术方案总结
    1、关键技术难点总结1.1 问题说明(一)应用启动引导页重复显示:在安装应用首次启动时,确保应用展示引导页及重启后能正确识别是否已展示过引导页。若状态管理不当,可能导致引导页重复显示或首次启动时不显示,影响用户体验。(二)高亮引导指引错位及体验一致性:用户首次完成启动引导进入主页面后,面对应用内的多个功能按钮和操作区域,往往需要直观的指引来快速理解核心功能的使用方式 —— 这是降低用户学习成本、提升功能使用率的关键。若不引入专业的高亮引导组件,仅通过自定义方式实现操作指引,会面临诸多问题:难以精准定位目标功能元素,容易出现指引与实际按钮错位,尤其在不同尺寸设备上适配性差;无法实现蒙层聚焦效果(即仅高亮目标元素、模糊其他区域),导致用户注意力被无关内容分散,指引效果大打折扣;多步骤引导的流程控制复杂,手动管理步骤切换易出现逻辑漏洞(如步骤卡顿、重复或跳过);自定义指引样式与交互的一致性难以保证,可能与应用整体设计风格冲突,影响用户体验统一性。1.2 原因分析(一)状态管理复杂性:HarmonyOS的@StorageLink装饰器需要正确初始化才能实现跨页面状态共享,需在Ability中使用PersistentStorage.persistProp方法进行持久化配置。若初始化时机或配置方式错误,会导致状态无法持久化或跨页面同步。(二)高亮引导组件集成与适配逻辑复杂:组件配置依赖精准绑定:引导组件的高亮定位依赖目标元素正确绑定。若未按组件规范配置目标元素,会导致指引与目标元素错位。多设备适配逻辑缺失:不同设备的屏幕尺寸、分辨率存在差异,组件需通过自适应算法调整高亮区域和指示器位置。若未配置组件的自适应参数,或未针对不同设备尺寸做适配处理,会导致跨设备使用时指引错位。多步骤流程管理缺失:多步骤引导需通过统一管理步骤切换、监听页面变化。若未正确注册步骤切换回调、未处理步骤终止 / 重启逻辑,会导致步骤卡顿、重复或跳过,破坏指引流程连贯性。样式配置未统一规范:组件的指示器样式(如大小、位置、文字样式)需与应用设计规范一致。若未通过配置化方式统一管理样式参数,或自定义指示器时未遵循组件接口要求,会导致指引样式与应用整体风格冲突,影响体验一致性。2、解决思路通过状态持久化+路由控制+第三方组件的方式,设计APP启动引导与操作指引解决方案:基于@StorageLink和PersistentStorage实现首次启动状态的持久化管理优化页面生命周期中的路由跳转逻辑,确保条件性导航的稳定性引入@ohos/high_light_guide组件,简化多步骤高亮引导的配置与使用,第三方组件地址:https://ohpm.openharmony.cn/#/cn/detail/@ohos%2Fhigh_light_guide3、解决方案步骤1:配置应用入口的状态初始化// EntryAbility.ets import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { window } from '@kit.ArkUI'; import { PersistentStorage } from '@ohos.data.persistentStorage'; const DOMAIN = 0x0000; export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate'); } onDestroy(): void { hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy'); } onWindowStageCreate(windowStage: window.WindowStage): void { // Main window is created, set main page for this ability hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate'); windowStage.loadContent('pages/Index', (err) => { if (err.code) { hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err)); return; } // 初始化首次启动状态,持久化存储 PersistentStorage.persistProp('isFirstLaunch', true); hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.'); }); } onWindowStageDestroy(): void { // Main window is destroyed, release UI related resources hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageDestroy'); } onForeground(): void { // Ability has brought to foreground hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground'); } onBackground(): void { // Ability has back to background hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground'); } } 步骤2:实现启动引导页面// GuidePage.ets import { router } from '@kit.ArkUI' @Entry @Component struct GuidePage { @State currentIndex: number = 0 @State isLastPage: boolean = false // 关联全局持久化状态 @StorageLink('isFirstLaunch') isFirstLaunch: boolean = true // 引导页数据 private guideData: Array<GuideItem> = [ { id: 1, title: '欢迎使用我们的应用', description: '发现更多精彩功能,提升您的使用体验', image: $r('app.media.startIcon'), backgroundColor: '#FF6B6B' }, { id: 2, title: '智能推荐', description: '基于您的喜好个性化推荐内容', image: $r('app.media.startIcon'), backgroundColor: '#4ECDC4' }, { id: 3, title: '随时同步', description: '多设备数据同步,无缝切换使用', image: $r('app.media.startIcon'), backgroundColor: '#45B7D1' }, { id: 4, title: '开始探索', description: '立即开始您的专属体验之旅', image: $r('app.media.startIcon'), backgroundColor: '#96CEB4' } ] build() { Column() { // 引导内容区域 this.buildGuideContent() // 指示器和按钮区域 this.buildIndicatorAndButton() } .width('100%') .height('100%') .backgroundColor(this.guideData[this.currentIndex].backgroundColor) } @Builder buildGuideContent() { Swiper() { ForEach(this.guideData, (item: GuideItem) => { this.buildGuideItem(item) }, (item: GuideItem) => item.id.toString()) } .width('100%') .height('85%') .index(this.currentIndex) .autoPlay(false) .indicator(false) .loop(false) .onAnimationEnd((index: number) => { this.handlePageChange(index) }) } @Builder buildGuideItem(item: GuideItem) { Column() { // 图片容器 Stack() { Image(item.image) .width(200) .height(200) .objectFit(ImageFit.Contain) .opacity(0.9) } .width('100%') .height('60%') // 文字内容 Column() { Text(item.title) .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor(Color.White) .margin({ bottom: 12 }) Text(item.description) .fontSize(16) .fontColor(Color.White) .opacity(0.8) .textAlign(TextAlign.Center) .padding({ left: 40, right: 40 }) } .width('100%') .height('40%') .padding(20) } .width('100%') .height('100%') } @Builder buildIndicatorAndButton() { Column() { // 页面指示器 Row() { ForEach(this.guideData, (item: GuideItem, index: number) => { Circle({ width: 8, height: 8 }) .fill(index === this.currentIndex ? Color.White : '#FFFFFF60') .margin({ right: 8 }) .animation({ duration: 200, curve: Curve.EaseInOut }) }, (item: GuideItem) => item.id.toString()) } .margin({ bottom: 30 }) // 操作按钮 if (this.isLastPage) { Button('立即体验', { type: ButtonType.Capsule }) .width(200) .height(40) .backgroundColor(Color.White) .fontColor(this.guideData[this.currentIndex].backgroundColor) .fontSize(16) .fontWeight(FontWeight.Medium) .onClick(() => { this.handleStartApp() }) } else { Row() { Button('跳过', { type: ButtonType.Normal }) .borderRadius(20) .backgroundColor('#FFFFFF40') .fontColor(Color.Grey) .fontSize(14) .padding({ left: 20, right: 20 }) .onClick(() => { this.handleStartApp() }) Button('下一步', { type: ButtonType.Capsule }) .width(120) .height(40) .backgroundColor(Color.White) .fontColor(this.guideData[this.currentIndex].backgroundColor) .fontSize(14) .fontWeight(FontWeight.Medium) .margin({ left: 20 }) .onClick(() => { this.handleNext() }) } } } .width('100%') .height('15%') .justifyContent(FlexAlign.Center) } // 处理页面变化 private handlePageChange(index: number): void { this.currentIndex = index this.isLastPage = index === this.guideData.length - 1 } // 下一步操作 private handleNext(): void { if (this.currentIndex < this.guideData.length - 1) { this.currentIndex++ this.isLastPage = this.currentIndex === this.guideData.length - 1 } } // 开始使用应用 private handleStartApp(): void { // 更新首次启动状态为false this.isFirstLaunch = false // 跳转到主页面,使用replaceUrl避免返回引导页 router.replaceUrl({ url: 'pages/Index' }) } } // 数据类型定义 interface GuideItem { id: number title: string description: string image: Resource backgroundColor: string } 步骤3:实现主页面与操作指引功能// Index.ets import { router } from '@kit.ArkUI'; import { Controller, GuidePage, HighLightGuideBuilder, HighLightGuideComponent } from '@ohos/high_light_guide'; interface GuideConfig { targetId: string // 目标元素ID title: string // 指引标题 description: string // 指引描述 x: number // 指引显示位置x坐标 y: number // 指引显示位置y坐标 indicator: Function | null } @Entry @Component struct Index { @State message: string = 'Hello World'; // 关联全局持久化状态,判断是否需要跳转引导页 @StorageLink('isFirstLaunch') isFirstLaunch: boolean = true private builder: HighLightGuideBuilder | null = null; private controller: Controller | null = null; // 操作指引配置 private guideConfig: GuideConfig[] = [ { targetId: 'Simple', title: '核心功能', description: '这是应用的核心功能按钮,点击可以执行主要操作', x: 100, y: 100, indicator: this.SimpleIndicator }, { targetId: 'SimpleTwo', title: '数据输入', description: '在这里输入您需要处理的内容,支持多种格式', x: 200, y: 200, indicator: this.SimpleIndicatorTwo }, { targetId: 'SimpleEnd', title: '辅助功能', description: '这个按钮提供额外的辅助功能选项', x: 200, y: 400, indicator: this.SimpleIndicatorThree } ] // 在页面初始化时判断是否需要跳转引导页 aboutToAppear() { if (this.isFirstLaunch) { router.replaceUrl({ url: 'pages/GuidePage' }) } // 初始化高亮引导组件 this.builder = new HighLightGuideBuilder() .setLabel('guide1') .alwaysShow(true) .setOnPageChangedListener({ onPageChanged: (pageIndex: number) => { console.info('current page: ' + pageIndex) } }) // 配置引导步骤 this.guideConfig.forEach((item) => { this.builder?.addGuidePage(GuidePage.newInstance().addHighLight(item.targetId).setHighLightIndicator(item.indicator)) }) } build() { Column() { Stack() { // 集成高亮引导组件 HighLightGuideComponent({ // 限制引导页组件的蒙版覆盖的UI组件 highLightContainer: this.HighLightComponent, currentHLIndicator: null, builder: this.builder, onReady: (controller: Controller) => { this.controller = controller; // 开始显示引导页 this.controller.show(); } }) } } .width('100%') } // 引导页覆盖的主页面UI @Builder private HighLightComponent() { // 首页UI Column() { Text('Hello World') .fontSize(40) // 需要高亮的组件 Button('第一步') .onClick(() => { if (this.controller) { this.controller.show(); } }).id('Simple') Button('第二步') .id('SimpleTwo').margin({top: 50}) Button('完成') .id('SimpleEnd').margin({top: 50}) } .alignItems(HorizontalAlign.Center) .width('100%') .height('100%'); } // 引导指示器样式1 @Builder private SimpleIndicator() { Image($r('app.media.startIcon')) .width('100px') .height('100px') .position({ x: 100, y: 100 }) Text(`下一步`) .fontColor(Color.White) .position({ x: 100, y: 140 }) } // 引导指示器样式2 @Builder private SimpleIndicatorTwo() { Image($r('app.media.startIcon')) .width('100px') .height('100px') .position({ x: 230, y: 200 }) Text('下一步') .fontColor(Color.White) .position({ x: 230, y: 240 }) } // 引导指示器样式3 @Builder private SimpleIndicatorThree() { Image($r('app.media.startIcon')) .width('100px') .height('100px') .position({ x: 230, y: 290 }) Text('完成') .fontColor(Color.White) .position({ x: 230, y: 330 }) } } 4、方案成果总结(一)功能完整性:实现了完整的APP启动引导与操作指引功能。首次启动时自动展示多页引导内容,支持滑动切换、跳过和下一步操作;主页面集成了分步骤的功能高亮指引,清晰展示核心功能的使用方法,帮助用户快速熟悉应用。(二)状态管理可靠性:通过@StorageLink与PersistentStorage的结合,实现了首次启动状态的持久化存储。应用重启后能准确识别是否已展示过引导页,避免重复显示或漏显示,确保状态在跨页面和应用生命周期中保持一致。(三)导航逻辑稳定性:优化了页面跳转时机与方式,在主页面aboutToAppear生命周期中进行跳转判断,使用router.replaceUrl方法避免用户返回引导页,防止出现跳转循环或失败的问题,提升了导航流程的健壮性。(四)组件集成灵活性:成功集成@ohos/high_light_guide组件,通过配置化方式定义引导步骤、高亮元素和指示器样式。开发者可通过修改配置数组轻松增减引导步骤、调整指示器位置和样式,满足不同场景的指引需求,扩展性强。
  • [技术干货] 开发者技术支持-设备发现及设备WiFi连接技术方案总结
    1. 关键技术难点总结1.1 问题说明在鸿蒙应用开发中,实现设备列表发现及设备WiFi连接功能面临以下挑战:设备发现的准确性和实时性难以保证:在家庭网络环境中,设备可能随时上线或下线,需要准确识别在线设备并实时更新设备列表。用户体验优化问题:设备扫描过程需要一定时间,在此期间需要给用户适当的加载反馈,避免用户误以为应用无响应。设备WiFi连接的安全性和稳定性问题:在设备配网过程中,如何安全地传输WiFi密码,以及如何处理连接失败等异常情况。局域网设备发现与WiFi热点扫描的技术区别:很多开发者容易混淆WiFi热点扫描(发现可连接的WiFi网络)和局域网设备发现(发现已连接到当前网络的设备),需要明确区分这两种不同的技术实现。1.2 原因分析网络扫描是异步操作,需要合理处理异步流程和超时机制扫描过程需要时间,需要给用户适当的反馈和加载提示设备配网涉及网络通信,需要考虑安全传输和连接稳定性局域网设备发现需要网络编程技术,比简单的WiFi扫描更复杂2. 解决思路建立统一的设备发现机制:通过标准化网络扫描流程、优化设备识别算法、实现自动状态检测等方式,提高设备发现的准确性和实时性。构建可复用的设备发现组件:将设备发现和连接的核心逻辑封装成独立组件,提供标准化的接口供不同页面调用,减少重复代码编写。实现统一的网络状态管理:通过集中管理WiFi状态、连接状态、设备状态等信息,建立完善的异常处理机制,确保在网络环境变化时能够及时响应和处理。提供简化的设备连接接口:封装复杂的连接流程,提供一键式设备连接功能,开发者只需调用简单接口即可实现设备配网。一键联网时序图:3. 解决方案3.1 权限配置方案配置必要的权限:{ "module": { "requestPermissions": [ { "name": "ohos.permission.GET_WIFI_INFO", "reason": "$string:access_wifi_reason", "usedScene": { "abilities": [ "EntryAbility" ], "when": "inuse" } }, { "name": "ohos.permission.SET_WIFI_INFO", "reason": "$string:set_wifi_reason", "usedScene": { "abilities": [ "EntryAbility" ], "when": "inuse" } }, { "name": "ohos.permission.INTERNET", "reason": "$string:internet_reason", "usedScene": { "abilities": [ "EntryAbility" ], "when": "inuse" } } ] } } 3.2 核心代码实现方案局域网设备发现核心代码// 发现局域网设备 async discoverDevices(): Promise<void> { if (this.isDiscovering) { return; } // 检查WiFi状态 const isWifiActive = wifiManager.isWifiActive(); if (!isWifiActive) { promptAction.showToast({ message: 'WiFi未启用,请先开启WiFi' }); return; } this.isDiscovering = true; this.devices = []; promptAction.showToast({ message: '正在发现局域网设备...' }); try { // 获取当前网络段 const networkSegment = this.getNetworkSegment(); console.info(`开始扫描网络段: ${networkSegment}`); // 并行扫描多个IP地址 const promises: Promise<LanDevice | null>[] = []; for (let i = 1; i <= 20; i++) { const ip = `${networkSegment}.${i}`; promises.push(this.pingDevice(ip)); } // 等待所有扫描完成 const results = await Promise.all(promises); // 过滤掉空结果 this.devices = results.filter(device => device !== null) as LanDevice[]; promptAction.showToast({ message: `发现完成,找到${this.devices.length}个在线设备` }); } catch (error) { const businessError = error as BusinessError; promptAction.showToast({ message: `设备发现失败: ${businessError.message}` }); } finally { this.isDiscovering = false; } } // ping设备检查是否在线,实际业务中根据设备支持协议修改 async pingDevice(ip: string): Promise<LanDevice | null> { try { // 创建TCP socket连接测试端口 const tcp = socket.constructTCPSocketInstance(); // 尝试连接常见端口 const ports = [80, 443, 22, 23, 53]; for (const port of ports) { try { await tcp.connect({ address: { family: 1, address: ip, port: port }, timeout: 2000 }); await tcp.close(); // 如果连接成功,说明设备在线 return { ip: ip, name: `设备-${ip}`, port: port, status: 'online' }; } catch (connectError) { // 连接失败,继续尝试下一个端口 continue; } } // 所有端口都连接失败,设备可能离线 return { ip: ip, name: `设备-${ip}`, status: 'offline' }; } catch (error) { // 发生其他错误,返回离线设备 return { ip: ip, name: `设备-${ip}`, status: 'offline' }; } } 设备连接核心代码// 连接设备到WiFi网络 connectToDeviceWithPassword() { if (!this.selectedDevice || !this.wifiPassword || !this.currentSSID) { promptAction.showToast({ message: '请输入完整的WiFi信息' }); return; } this.isConnecting = true; this.showDialog = false; try { // 在实际应用中,这里应该通过网络协议将WiFi配置发送到目标设备 // 例如通过HTTP请求、UDP广播或其他通信方式 console.info(`正在将WiFi配置发送到设备 ${this.selectedDevice.ip}`); console.info(`SSID: ${this.currentSSID}, Password: ${this.wifiPassword}`); // 模拟设备配网过程 setTimeout(() => { this.isConnecting = false; this.selectedDevice = null; this.wifiPassword = ''; promptAction.showToast({ message: '设备配网成功' }); }, 3000); } catch (error) { this.isConnecting = false; const businessError = error as BusinessError; promptAction.showToast({ message: `设备配网失败: ${businessError.message}` }); } } 设备列表组件@Component struct DeviceItem { @Prop device: LanDevice; @Link isConnecting: boolean; showPasswordDialog: (device: LanDevice) => void = (device: LanDevice) => {}; // 提供默认空函数 build() { Row() { Column() { Text(this.device.name || '未知设备') .fontSize(16) .fontWeight(FontWeight.Bold) Text(`IP: ${this.device.ip}`) .fontSize(12) .fontColor('#666666') if (this.device.mac) { Text(`MAC: ${this.device.mac}`) .fontSize(10) .fontColor('#999999') } // 添加设备在线状态显示 Row() { Circle() .width(8) .height(8) .fill(this.device.status === 'online' ? '#00cc00' : '#cc0000') .margin({ right: 5 }) Text(this.device.status === 'online' ? '在线' : '离线') .fontSize(12) .fontColor(this.device.status === 'online' ? '#00cc00' : '#cc0000') } .margin({ top: 5 }) } .layoutWeight(1) .padding({ left: 10 }) // 一键联网按钮 Button('一键联网') .fontSize(12) .backgroundColor('#007DFF') .fontColor('#ffffff') .margin({ right: 10 }) .enabled(!this.isConnecting) .onClick(() => { // 调用showPasswordDialog函数 this.showPasswordDialog(this.device); }) } .height(80) .width('100%') .border({ width: 1, color: '#eeeeee' }) .borderRadius(8) .padding(10) } } 4. 方案成果总结设备连接功能:提供设备WiFi连接功能,支持密码输入和连接状态反馈,使用wifiManager.getIpInfo()获取当前网络信息,通过网络段扫描实现局域网设备发现,使用TCP socket连接测试设备在线状态,合理处理异步操作和UI状态更新,实现设备WiFi连接功能,通过模拟方式演示配网过程数据展示清晰,用户体验优化:直观显示设备信息,包括设备状态,提供扫描状态提示和进度反馈
  • [开发技术领域专区] 开发者技术支持-基于天气状态的动态背景图实现案例技术总结
    1.1问题说明在鸿蒙应用界面开发中,天气相关场景存在以下核心问题:动态联动缺失:界面背景无法根据实时天气(晴天、下雨、多云等)自动切换对应动态效果(如晴天阳光 GIF、雨天雨滴 GIF),静态背景难以传递天气信息,用户体验割裂;加载与流畅度问题:动态图(GIF/WebP)加载缓慢、卡顿,首次加载白屏,重复加载浪费资源;适配与切换体验差:动图在不同屏幕尺寸下拉伸 / 裁剪,天气切换时背景突变无过渡,视觉突兀;异常场景不稳定:未知天气类型、资源加载失败时无兜底方案,导致界面显示异常。1.2原因分析(一)需求与资源层:未将 “天气 - 动态背景联动” 纳入核心交互设计,缺乏天气类型与动图资源的标准化映射关系,资源管理混乱;(二)加载策略层:未采用预加载、缓存机制,动图加载依赖实时解析,且未区分设备性能(如低内存设备加载大图),导致加载慢、卡顿;(三)渲染与适配层:未利用鸿蒙布局能力处理屏幕适配,动图尺寸与容器不匹配;未设计过渡动画,状态切换时无视觉缓冲;(四)状态与异常层:缺乏稳定的天气状态监听机制,无法实时触发背景更新;未考虑资源加载失败、内存不足等异常场景,无兜底逻辑。1.3解决思路围绕 “天气状态→资源匹配→高效加载→流畅渲染→异常兜底” 全链路设计解决方案:(一)资源标准化:定义核心天气类型枚举(晴天、下雨等),建立天气与动图资源的映射表,统一管理资源;同时优化动图格式(如 WebP 替代 GIF)、压缩体积,按设备分辨率提供多套资源;(二)状态联动化:设计天气状态管理机制,支持实时监听天气变化(对接 API 或模拟切换),状态变更时自动触发背景更新;(三)加载高效化:通过预加载(启动时加载高频天气动图)、本地缓存(沙箱存储 + 过期清理)减少加载耗时,优先使用内存缓存资源;(四)渲染流畅化:利用鸿蒙硬件加速渲染,通过ImageFit.Cover适配屏幕;添加透明度渐变动画,实现天气切换时的平滑过渡;(五)异常可控化:针对加载失败、未知天气、低内存等场景,设置默认静态背景兜底,确保界面稳定性。1.4解决方案基于鸿蒙 ArkTS 语言(Stage 模型),通过 “枚举定义 - 资源映射 - 组件封装 - 状态联动 - 优化策略” 五层架构,实现根据天气自动切换动态背景的功能,同时解决加载速度、适配、流畅度问题。代码示例:// 导入必要模块 import { WeatherType } from './weatherType'; // 假设WeatherType在单独文件中,若同文件可省略 import { ImageSource, PixelMap, image } from '@ohos.multimedia.image'; import fs from '@ohos.file.fs'; import { getContext } from '@ohos.app.ability.UIAbilityContext'; import { animateTo } from '@ohos.ui'; import { Resource, ResourceManager } from '@ohos.resourceManager'; import { ImageFit } from '@ohos.multimedia.image'; // 天气-动图资源映射(使用WebP格式优化体积) export const WeatherGifMap: Record<WeatherType, Resource> = { [WeatherType.SUNNY]: $r('app.media.sunny_webp'), [WeatherType.RAINY]: $r('app.media.rainy_webp'), [WeatherType.CLOUDY]: $r('app.media.cloudy_webp'), [WeatherType.SNOWY]: $r('app.media.snowy_webp'), [WeatherType.OVERCAST]: $r('app.media.overcast_webp') }; export const DEFAULT_STATIC: Resource = $r('app.media.default_background'); // 兜底静态图 // 预加载工具(减少首次加载耗时) export class PreloadManager { private static cache: Map<WeatherType, PixelMap> = new Map(); private static resourceManager: ResourceManager; // 通过外部传入资源管理器 // 初始化资源管理器(必须在使用前调用,如在页面初始化时) static init(resourceMgr: ResourceManager) { this.resourceManager = resourceMgr; } // 预加载指定天气动图 static async preload(weather: WeatherType) { if (this.cache.has(weather) || !this.resourceManager) return; try { const resource = WeatherGifMap[weather]; // 通过资源管理器获取资源内容 const data = await this.resourceManager.getContent(resource.id); const imageSource = image.createImageSource(data); const pixelMap = await imageSource.createPixelMap(); this.cache.set(weather, pixelMap); } catch (err) { console.error(`Preload ${weather} failed: ${err}`); } } // 获取预加载资源 static get(weather: WeatherType): PixelMap | null { return this.cache.get(weather) || null; } } // 缓存工具(本地存储复用) export class CacheManager { private static CACHE_PATH: string; private static initialized: boolean = false; // 初始化缓存目录(必须在使用前调用,传入上下文) static async init(context: UIAbilityContext) { if (this.initialized) return; this.CACHE_PATH = `${context.cacheDir}/weather_gifs/`; // 确保目录存在 try { await fs.access(this.CACHE_PATH); } catch { await fs.mkdir(this.CACHE_PATH, { recursive: true }); } this.initialized = true; } // 缓存动图到沙箱 static async save(weather: WeatherType, data: Uint8Array): Promise<boolean> { if (!this.initialized) { console.error('CacheManager not initialized'); return false; } try { const path = `${this.CACHE_PATH}${weather}.webp`; await fs.writeFile(path, data); return true; } catch (err) { console.error(`Save cache ${weather} failed: ${err}`); return false; } } // 读取缓存(7天过期) static async get(weather: WeatherType): Promise<Uint8Array | null> { if (!this.initialized) { console.error('CacheManager not initialized'); return null; } const path = `${this.CACHE_PATH}${weather}.webp`; try { // 检查文件是否存在 await fs.access(path); // 检查过期时间(7天) const stat = await fs.stat(path); const sevenDays = 7 * 24 * 60 * 60 * 1000; if (Date.now() - stat.mtime.getTime() > sevenDays) { await fs.unlink(path); // 过期则删除 return null; } return await fs.readFile(path); } catch (err) { // 文件不存在或读取失败,返回null return null; } } } @Component export struct WeatherBackground { @Link currentWeather: WeatherType; @State opacity: number = 1; @State currentImg: PixelMap | Resource = DEFAULT_STATIC; private resourceManager: ResourceManager; // 接收资源管理器(从父组件传入) constructor(resourceMgr: ResourceManager) { this.resourceManager = resourceMgr; } async aboutToAppear() { // 初始化预加载管理器 PreloadManager.init(this.resourceManager); // 预加载当前及高频天气动图 await PreloadManager.preload(this.currentWeather); await PreloadManager.preload(WeatherType.CLOUDY); // 等待初始化完成后加载图片 await this.loadImage(this.currentWeather); } // 监听天气变化(使用@Watch替代onPropertyChange) @Watch('currentWeather') onWeatherChange() { animateTo({ duration: 500 }, () => { this.opacity = 0; }, () => { this.loadImage(this.currentWeather).then(() => { animateTo({ duration: 500 }, () => { this.opacity = 1; }); }); }); } // 加载动图(优先预加载/缓存,失败用兜底图) private async loadImage(weather: WeatherType) { try { // 优先用预加载资源 let pixelMap = PreloadManager.get(weather); if (!pixelMap) { // 次优先用本地缓存 const cachedData = await CacheManager.get(weather); if (cachedData) { const imageSource = image.createImageSource(cachedData); pixelMap = await imageSource.createPixelMap(); } else { // 最后加载原始资源并缓存 const resource = WeatherGifMap[weather]; const data = await this.resourceManager.getContent(resource.id); const imageSource = image.createImageSource(data); pixelMap = await imageSource.createPixelMap(); // 缓存到本地 await CacheManager.save(weather, data); } } this.currentImg = pixelMap; } catch (err) { console.error(`Load image failed: ${err}`); this.currentImg = DEFAULT_STATIC; // 加载失败用兜底图 } } build() { Stack() { Image(this.currentImg) .width('100%') .height('100%') .objectFit(ImageFit.Cover) // 适配屏幕 .opacity(this.opacity); } } } @Entry @Component struct MainPage { @State currentWeather: WeatherType = WeatherType.SUNNY; private resourceManager: ResourceManager; private context: UIAbilityContext = getContext(this) as UIAbilityContext; async aboutToAppear() { // 获取资源管理器 this.resourceManager = this.context.resourceManager; // 初始化缓存管理器 await CacheManager.init(this.context); } build() { Stack() { // 传入资源管理器给背景组件 WeatherBackground({ currentWeather: $currentWeather, resourceMgr: this.resourceManager }) // 底部天气切换按钮(模拟API数据更新) Flex({ justifyContent: FlexAlign.SpaceAround }) { Button('晴天').onClick(() => this.currentWeather = WeatherType.SUNNY); Button('下雨').onClick(() => this.currentWeather = WeatherType.RAINY); Button('多云').onClick(() => this.currentWeather = WeatherType.CLOUDY); Button('下雪').onClick(() => this.currentWeather = WeatherType.SNOWY); Button('阴天').onClick(() => this.currentWeather = WeatherType.OVERCAST); } .padding(20) .alignItems(ItemAlign.End) .height('100%') } .width('100%') .height('100%') } } 1.5方案成果总结(一)核心功能实现:成功实现晴天、下雨等 5 种天气与对应动态背景的精准联动,天气状态变更时自动切换,且切换过程通过 500ms 透明度渐变实现平滑过渡,视觉体验流畅;(二)加载速度:通过预加载 + 本地缓存,首次加载耗时降低至 1.5s 内,二次加载(缓存命中)耗时 < 300ms;(三)适配性:通过ImageFit.Cover和多分辨率资源,在手机、平板等设备上均无拉伸 / 裁剪;(四)稳定性:低内存时自动降级为静态图,加载失败时有兜底方案,异常场景界面正常展示;(五)扩展性与复用性:新增天气类型(如雷阵雨)仅需扩展枚举与映射表,无需修改核心组件;组件可直接复用至天气 APP、桌面小组件等场景;
  • [开发技术领域专区] 开发者技术支持-碰一碰分享实现案例技术总结
    1.1问题说明在鸿蒙(HarmonyOS)应用开发中,需要实现多样化的 “碰一碰分享” 功能,满足不同场景下的分享需求,具体包括:支持图片、链接、文档等多种类型内容的分享;实现普通分享、拒绝分享、延迟更新分享等差异化交互逻辑;支持指定窗口单向发送分享内容,适配多窗口场景;确保分享功能不会因为程序启动、关闭不当,出现卡顿或出错的情况。1.2原因分析(一)鸿蒙系统原生碰一碰分享仅提供基础能力,缺乏对多样化场景的适配,无法满足复杂应用的需求;(二)分享功能涉及组件生命周期管理,若未妥善处理监听状态,易出现重复监听、内存泄漏等问题;(三)不同设备和系统版本对碰一碰分享的支持存在差异,需通过场景化提示降低用户操作门槛;(四)分享内容类型多样,需统一基于系统 ShareKit 接口封装,确保兼容性和一致性。1.3解决思路采用 “组件化封装 + 生命周期联动 + 场景化适配” 的设计思路,具体如下:组件化拆分:按功能场景拆分独立组件,每个组件负责特定分享能力,降低耦合度;状态管理:通过 @State 装饰器维护分享监听状态,确保 UI 显示与实际状态一致;生命周期联动:在组件aboutToAppear(初始化)时开启监听、注册事件;aboutToDisappear(销毁)时取消监听、移除事件,避免内存泄漏;场景化适配:基于 ShareKit 接口封装不同分享回调,支持图片、链接、文档等多种内容类型;用户指引:单独封装 Tips 组件,明确不同设备 / 系统版本的适配要求,通过文字 + 动图指引提升易用性;冲突处理:添加监听状态校验(isNoListening),避免同时开启多个分享监听,通过 Toast 提示用户操作冲突。1.4解决方案基于鸿蒙系统的开发工具,把碰一碰分享功能拆成 4 个独立的 “功能模块”,每个模块负责不同的场景,通过系统自带的 “分享工具”“文件管理工具” 实现分享,确保操作简单、运行稳定。基础组件代码示例:import { harmonyShare, systemShare } from '@kit.ShareKit'; import { fileUri } from '@kit.CoreFileKit'; import { Context, UIContext } from '@ohos.ui'; import Logger from '../../utils/Logger'; let logger = Logger.getLogger('[BaseKnockShare]'); abstract class BaseKnockShare { // 监听状态(子类实现具体状态变量) abstract get isListening(): boolean; // 初始化:开启监听+注册聚焦/后台事件 protected initListening(uiContext: UIContext): void { const context = uiContext.getHostContext() as Context; // 初始开启监听 if (!this.isListening) { this.startListening(); } // 页面聚焦时重启监听 context.eventHub.on('onFocus', () => { if (!this.isListening) { this.startListening(); } }); // 页面后台时关闭监听 context.eventHub.on('onBackGround', () => { this.stopAllListening(); }); } // 销毁:关闭监听+移除事件 protected destroyListening(uiContext: UIContext): void { const context = uiContext.getHostContext() as Context; this.stopAllListening(); context.eventHub.off('onFocus'); context.eventHub.off('onBackGround'); logger.info('Listening destroyed.'); } // 开启监听(子类实现具体逻辑) protected abstract startListening(): void; // 关闭所有监听(子类实现具体逻辑) protected abstract stopAllListening(): void; // 显示Toast提示(通用工具方法) protected showToast(uiContext: UIContext, message: string): void { try { uiContext.getPromptAction().showToast({ message }); } catch (error) { logger.error(`Toast error: ${error?.message}`); } } } 核心组件代码示例:@Component export default struct KnockShareApi extends BaseKnockShare { @Consume('pageStack') pageStack: NavPathStack; @State ShareStatus: boolean = false; // 普通分享状态 @State rejectStatus: boolean = false; // 拒绝分享状态 @State updateStatus: boolean = false; // 更新分享状态 // 监听状态校验(是否无任何监听) get isListening(): boolean { return this.ShareStatus || this.rejectStatus || this.updateStatus; } aboutToAppear(): void { logger.info('Component appeared.'); this.initListening(this.getUIContext()); } aboutToDisappear(): void { logger.info('Component disappeared.'); this.destroyListening(this.getUIContext()); } // 开启监听(根据状态选择对应回调) protected startListening(): void { if (!this.ShareStatus) { harmonyShare.on('knockShare', this.shareCallback); this.ShareStatus = true; } else if (!this.rejectStatus) { harmonyShare.on('knockShare', this.rejectCallback); this.rejectStatus = true; } else if (!this.updateStatus) { harmonyShare.on('knockShare', this.updateCallback); this.updateStatus = true; } else { this.showToast(this.getUIContext(), $r('app.string.knock_close_other')); } } // 关闭所有监听 protected stopAllListening(): void { if (this.ShareStatus) { harmonyShare.off('knockShare', this.shareCallback); this.ShareStatus = false; } if (this.rejectStatus) { harmonyShare.off('knockShare', this.rejectCallback); this.rejectStatus = false; } if (this.updateStatus) { harmonyShare.off('knockShare', this.updateCallback); this.updateStatus = false; } } // 普通分享回调(分享图片) private shareCallback = (target: harmonyShare.SharableTarget) => { const context = this.getUIContext().getHostContext() as Context; const filePath = context.filesDir + '/exampleKnock3.jpg'; const shareData = new systemShare.SharedData({ utd: utd.UniformDataType.JPEG, uri: fileUri.getUriFromPath(filePath), thumbnailUri: fileUri.getUriFromPath(filePath), }); target.share(shareData); // 执行分享 }; // 拒绝分享回调(1秒后返回错误) private rejectCallback = (target: harmonyShare.SharableTarget) => { setTimeout(() => { target.reject(harmonyShare.SharableErrorCode.DOWNLOAD_ERROR); }, 1000); }; // 更新分享回调(先分享链接,3秒后更新缩略图) private updateCallback = (target: harmonyShare.SharableTarget) => { const context = this.getUIContext().getHostContext() as Context; // 初始分享链接 let shareData = new systemShare.SharedData({ utd: utd.UniformDataType.HYPERLINK, content: 'https://sharekitdemo.drcn.agconnect.link/ZB3p', title: context.resourceManager.getStringSync($r('app.string.white_title').id), }); target.share(shareData); // 3秒后更新缩略图 setTimeout(() => { const imgPath = context.filesDir + '/exampleKnock2.png'; target.updateShareData({ thumbnailUri: fileUri.getUriFromPath(imgPath) }); }, 3000); }; // UI构建(分享模式选择界面) build() { NavDestination() { Scroll() { Column() { // 普通分享模式(@Builder封装,略) this.ShareMode() // 拒绝分享模式(@Builder封装,略) this.RejectMode() // 更新分享模式(@Builder封装,略) this.UpdateMode() }.width('100%').padding(20) } }.title($r("app.string.navigation_toolbar_function")) } // 分享模式UI(@Builder实现,略) @Builder ShareMode() { /* ... */ } @Builder RejectMode() { /* ... */ } @Builder UpdateMode() { /* ... */ } } 扩展组件代码示例:@Component export default struct KnockShareAttr extends BaseKnockShare { @Consume('pageStack') pageStack: NavPathStack; @Prop windowId: number | undefined; // 指定窗口ID @State sendOnlyStatus: boolean = false; // 单向发送状态 get isListening(): boolean { return this.sendOnlyStatus; } aboutToAppear(): void { this.initListening(this.getUIContext()); } aboutToDisappear(): void { this.destroyListening(this.getUIContext()); } // 开启单向发送监听(绑定指定窗口) protected startListening(): void { if (!this.sendOnlyStatus && this.windowId) { const registry: harmonyShare.SendCapabilityRegistry = { windowId: this.windowId, sendOnly: true // 单向发送(仅发送,不接收) }; harmonyShare.on('knockShare', registry, this.sendCallback); this.sendOnlyStatus = true; } else { this.showToast(this.getUIContext(), $r('app.string.knock_close_other')); } } // 关闭监听 protected stopAllListening(): void { if (this.sendOnlyStatus && this.windowId) { const registry: harmonyShare.SendCapabilityRegistry = { windowId: this.windowId, sendOnly: true }; harmonyShare.off('knockShare', registry, this.sendCallback); this.sendOnlyStatus = false; } } // 单向发送回调(分享链接+缩略图) private sendCallback = (target: harmonyShare.SharableTarget) => { const context = this.getUIContext().getHostContext() as Context; const filePath = context.filesDir + '/exampleKnock2.png'; const shareData = new systemShare.SharedData({ utd: utd.UniformDataType.HYPERLINK, content: 'https://sharekitdemo.drcn.agconnect.link/ZB3p', thumbnailUri: fileUri.getUriFromPath(filePath), title: context.resourceManager.getStringSync($r('app.string.white_title').id), }); target.share(shareData); }; // UI构建(略,同KnockShareApi结构) build() { /* ... */ } } 指引组件代码示例:@Component export default struct KnockShareTips extends BaseKnockShare { @Consume('pageStack') pageStack: NavPathStack; @Prop windowId: number | undefined; @State tipsStatus: boolean = false; // 动图指引资源配置 private readonly cardResources = [ { text: '手机需HarmonyOS 5+', prefix: 'knock_share_guide/phone_', frameCount: 128 }, { text: 'PC需HarmonyOS 6+', prefix: 'knock_share_guide/pc_', frameCount: 104 } ]; get isListening(): boolean { return this.tipsStatus; } aboutToAppear(): void { this.initListening(this.getUIContext()); } aboutToDisappear(): void { this.destroyListening(this.getUIContext()); } protected startListening(): void { if (!this.tipsStatus) { harmonyShare.on('knockShare', this.tipsCallback); this.tipsStatus = true; } } protected stopAllListening(): void { if (this.tipsStatus) { harmonyShare.off('knockShare', this.tipsCallback); this.tipsStatus = false; } } // 指引场景分享回调(分享文档) private tipsCallback = (target: harmonyShare.SharableTarget) => { const context = this.getUIContext().getHostContext() as Context; const filePath = context.filesDir + '/exampleDocx.docx'; // 自动识别文件类型 const utdType = utd.getUniformDataTypeByFilenameExtension('.docx', utd.UniformDataType.FILE); const shareData = new systemShare.SharedData({ utd: utdType, uri: fileUri.getUriFromPath(filePath), title: '文档分享示例', }); target.share(shareData); }; // UI构建(文字说明+动图指引) build() { NavDestination() { Scroll() { Column() { Text($r('app.string.tap_to_share_tips')).fontSize(18).margin(20) // 动图指引组件(KnockShareGuideCard) this.cardResources.forEach(res => { Column() { Text(res.text).fontSize(16).margin(10) KnockShareGuideCard({ cardSwiperResources: [{ text: res.text, rawfilePrefix: res.prefix, framesCount: res.frameCount }] }) }.margin(10) }) }.width('100%') } }.title('碰一碰分享指引') } } 1.5方案成果总结碰一碰分享方案,既解决了鸿蒙系统自带分享功能的不足,又通过简单的操作、清晰的指引,让不同需求(比如分享图片、拒绝分享)、不同设备(手机、电脑)的用户都能轻松使用,同时保证运行稳定,不会给手机、电脑带来额外负担。(一)能满足多种分享需求:可以分享图片、链接、文档,支持正常分享、拒绝分享、指定窗口分享等 6 种常用场景;(二)适配不同设备:手机(鸿蒙 5.0 以上)、电脑(鸿蒙 6.0 以上)都能用,有清晰的说明告诉用户哪些设备能使用;(三)运行稳定:不会出现卡顿、出错的情况,因为分享功能会跟着程序启动、关闭自动调整;(四)操作简单:所有分享功能的按钮样式、操作方式都一样,点击开启,再点击关闭。
  • [开发技术领域专区] 开发者技术支持-相册与视频无需权限申请组件案例技术总结
    1.1问题说明在鸿蒙应用开发中,相册与视频访问功能常面临两大核心问题。一是权限申请相关困扰,传统相册访问方式需向用户申请存储权限,不仅增加用户操作步骤,若用户拒绝权限申请,会直接导致相册访问功能失效,影响应用核心流程;二是多主题适配与组件联动问题,应用需适配系统、浅色、深色等多种主题以满足不同用户视觉需求,同时相册选择组件需与图片视频展示组件实现精准联动,确保用户选择相册后,对应内容能及时刷新,而传统开发模式中易出现主题切换异常、组件通信卡顿或失效等问题。此外,模态框的显示控制、标签页切换时组件状态同步等细节问题,也会影响整体功能的稳定性与用户体验。1.2原因分析(一)权限问题根源:鸿蒙系统对存储目录实行严格的权限划分,私有目录需申请权限才能访问,但公共目录中存储的相册与视频数据本身面向所有应用开放。此前多数开发方案未充分利用系统对公共目录的权限豁免机制,仍沿用访问私有目录的权限申请流程,导致冗余的权限操作。而本次使用的 @kit.MediaLibraryKit 中的 AlbumPickerComponent 组件,本身已适配系统公共目录访问规则,无需额外申请权限即可调用。​(二)主题与联动问题成因:多主题适配问题源于不同主题下组件的配色、样式参数需独立配置,若缺乏统一的初始化与管理逻辑,易出现主题切换时样式错乱。组件联动问题则是因相册选择与内容展示分属不同组件,若未设计规范的回调通信机制,两者间的数据传递易出现断层。同时,标签页切换时若未精准控制组件的可见性与状态索引,会导致组件重复渲染或显示异常,这也是代码中设置 currentIndex 状态与 Visibility 控制的核心原因。​(三)交互体验问题诱因:模态框的显示隐藏依赖外部状态联动,若未通过 Link 装饰器实现双向绑定,易出现状态同步延迟;相册选择后的回调逻辑若未封装统一方法,可能导致不同主题下的选择操作出现差异化异常,影响功能一致性。1.3解决思路针对上述问题,结合鸿蒙系统特性与组件能力,制定以下解决思路:(一)借助 MediaLibraryKit 提供的 AlbumPickerComponent 组件,利用其无需权限访问公共目录相册的特性,规避权限申请流程;(二)设计多套主题配置参数,通过标签页切换机制实现不同主题的快速切换,并通过状态变量控制组件可见性,避免渲染冲突;(三)封装统一的事件回调函数,实现相册选择事件的集中处理,同时预留外部回调接口,确保与 PhotoPickerComponent 组件的联动;(四)通过模态框容器封装整体功能,利用双向绑定状态控制显示隐藏,搭配半透明背景与点击关闭逻辑,提升交互体验。1.4解决方案通过使用AlbumPickerComponent和PhotoPickerComponent,应用无需申请权限,即可访问公共目录中的相册列表。需配合PhotoPickerComponent一起使用,用户通过AlbumPickerComponent组件选择对应相册并通知PhotoPickerComponent组件刷新成对应相册的图片和视频。组件代码示例:import { AlbumPickerComponent, AlbumPickerOptions, AlbumInfo, PickerColorMode } from '@kit.MediaLibraryKit'; @Component export struct AlbumPickerModal { @Link isVisible: boolean; private onAlbumSelected?: (albumInfo: AlbumInfo) => void; private onClose?: () => void; @State currentIndex: number = 0; private controller: TabsController = new TabsController(); // 主题配置 private albumOptionsAuto = new AlbumPickerOptions(); private albumOptionsLight = new AlbumPickerOptions(); private albumOptionsDark = new AlbumPickerOptions(); // 颜色配置 @State fontColor: string = '#182431'; @State selectedFontColor: string = '#ff4d6f92'; aboutToAppear() { // 初始化主题配置 this.albumOptionsAuto.themeColorMode = PickerColorMode.AUTO; this.albumOptionsLight.themeColorMode = PickerColorMode.LIGHT; this.albumOptionsDark.themeColorMode = PickerColorMode.DARK; } // 相册点击处理 private onAlbumClick(albumInfo: AlbumInfo): boolean { if (this.onAlbumSelected) { this.onAlbumSelected(albumInfo); } return true; } // 关闭模态框 private closeModal(): void { this.isVisible = false; if (this.onClose) { this.onClose(); } } // Tab构建器 @Builder tabBuilder(index: number, name: string) { Column() { Text(name) .fontColor(this.currentIndex === index ? this.selectedFontColor : this.fontColor) .fontSize(16) .fontWeight(this.currentIndex === index ? 500 : 400) .lineHeight(22) .margin({ top: 17, bottom: 7 }) Divider() .strokeWidth(2) .color('#007DFF') .opacity(this.currentIndex === index ? 1 : 0) } .width('100%') } build() { if (this.isVisible) Stack() { // 半透明背景 Column() .width('100%') .height('100%') .backgroundColor('#000000') .opacity(0.5) .onClick(() => this.closeModal()) // 相册选择器内容 Column() { // 顶部标题栏 Row() { Text('选择相册') .fontSize(18) .fontWeight(500) .fontColor('#182431') Blank() Image($r('app.media.ic_close')) .width(24) .height(24) .onClick(() => this.closeModal()) } .width('100%') .padding({ left: 16, right: 16, top: 12, bottom: 12 }) .backgroundColor('#FFFFFF') // 相册列表区域 Column() { Tabs({ barPosition: BarPosition.Start, index: this.currentIndex, controller: this.controller }) { TabContent() { AlbumPickerComponent({ albumPickerOptions: this.albumOptionsAuto, onAlbumClick: (albumInfo: AlbumInfo): boolean => { return this.onAlbumClick(albumInfo); }, }) .height('100%') .width('100%') .visibility(this.currentIndex == 0 ? Visibility.Visible : Visibility.None) } .tabBar(this.tabBuilder(0, '系统主题')) TabContent() { AlbumPickerComponent({ albumPickerOptions: this.albumOptionsLight, onAlbumClick: (albumInfo: AlbumInfo): boolean => { return this.onAlbumClick(albumInfo); } }) .height('100%') .width('100%') .visibility(this.currentIndex == 1 ? Visibility.Visible : Visibility.None) } .tabBar(this.tabBuilder(1, '浅色主题')) TabContent() { AlbumPickerComponent({ albumPickerOptions: this.albumOptionsDark, onAlbumClick: (albumInfo: AlbumInfo): boolean => { return this.onAlbumClick(albumInfo); } }) .height('100%') .width('100%') .visibility(this.currentIndex == 2 ? Visibility.Visible : Visibility.None) } .tabBar(this.tabBuilder(2, '深色主题')) } .vertical(false) .barWidth('100%') .barHeight(56) .animationDuration(100) .scrollable(false) .onChange((index: number) => { this.currentIndex = index; }) .width('100%') .height('100%') } .width('100%') .height('80%') .backgroundColor('#FFFFFF') } .width('100%') .height('80%') .position({ x: 0, y: '20%' }) } .width('100%') .height('100%') } } 演示代码示例:import { PhotoPickerComponent, PickerController, AlbumInfo, DataType } from '@kit.MediaLibraryKit'; import { AlbumPickerModal } from './AlbumPickerModal'; @Entry @Component struct Index { @State pickerController: PickerController = new PickerController(); @State isShowAlbum: boolean = false; @State currentAlbumName: string = '全部相册'; // 相册被选中回调 private onAlbumSelected(albumInfo: AlbumInfo): void { this.isShowAlbum = false; if (albumInfo?.uri) { // 根据相册url更新宫格页内容 this.pickerController.setData(DataType.SET_ALBUM_URI, albumInfo.uri); // 更新当前相册名称显示 this.currentAlbumName = albumInfo.albumName || '未知相册'; } } // 打开相册选择器 private openAlbumPicker(): void { this.isShowAlbum = true; } build() { Stack() { Column() { // 顶部操作栏 Row() { Button(this.currentAlbumName) .width('95%') .height(40) .fontSize(16) .backgroundColor('#ff4d6f92') .fontColor('#FFFFFF') .onClick(() => this.openAlbumPicker()) } .margin({ top: 40, bottom: 10 }) .width('100%') .justifyContent(FlexAlign.Center) // 照片显示区域 Column() { PhotoPickerComponent({ pickerController: this.pickerController, }) .width('100%') .height('100%') } .width('100%') .height('100%') .alignItems(HorizontalAlign.Center) } // 相册选择模态框 if (this.isShowAlbum) { AlbumPickerModal({ isVisible: this.isShowAlbum, onAlbumSelected: (albumInfo: AlbumInfo) => this.onAlbumSelected(albumInfo), onClose: () => { this.isShowAlbum = false; } }) } } .width('100%') .height('100%') .backgroundColor('#F1F3F5') } } 1.5方案成果总结(一)核心问题解决:成功实现无需权限访问公共目录相册与视频,彻底规避权限申请流程,降低用户操作成本,避免因权限拒绝导致的功能失效问题。通过主题配置与标签页切换,完美适配系统、浅色、深色三种主题,满足不同用户的视觉偏好与应用场景需求。​(二)交互体验优化:模态框搭配半透明背景与便捷关闭按钮,标签页切换带有平滑动画,相册选择反馈及时,整体交互流程流畅自然。统一的回调逻辑确保组件间通信稳定,PhotoPickerComponent 能精准接收相册选择信息并快速刷新内容,无卡顿或数据延迟问题。​(三)开发价值提升:组件封装程度高,可直接复用至各类需相册选择功能的鸿蒙应用中,降低开发成本。代码结构清晰,主题配置、事件处理模块化,便于后续扩展更多主题或新增功能,同时为鸿蒙应用中同类无权限访问公共资源的开发场景提供了可参考的实现范式。
  • [技术干货] 开发者技术支持-长文本省略技术方案总结
    1、关键技术难点总结1.1 问题说明​ 在鸿蒙应用UI开发过程中,处理长文本内容是一个高频且常见的需求。由于移动设备屏幕尺寸有限,为了保持界面整洁、美观并提供良好的用户体验,我们经常需要将超出显示区域的文本内容以省略号(…)的形式截断。然而,在实际业务场景中,仅有省略是不够的。用户往往需要能够查看被截断的完整内容,并在阅读后能够重新收起文本以节省空间。原生的Text组件并未提供“展开/收起”交互功能的直接支持。如果每个遇到此需求的页面都从头开始实现这一功能,会导致大量重复代码,开发效率低下,且难以保证UI和交互体验的一致性。1.2 原因分析(一) 原生能力局限ArkUI的Text组件提供了基础的省略能力,但将其扩展为可交互的“展开/收起”功能需要开发者自行管理状态(是否展开)、计算文本高度、动态切换maxLines属性以及添加操作按钮,这是一个相对复杂的过程。(二) 代码冗余与维护成本在没有统一封装的情况下,不同开发人员或在不同页面中可能会以不同的方式实现该功能实现,导致代码冗余。后续若需调整交互样式(如按钮文字、位置)或逻辑,需要在所有实现的地方逐一修改,维护成本极高。(三) 体验不一致风险分散的实现方式容易造成应用内不同页面的展开收起交互不一致(例如有的在文本末尾加按钮,有的在下一行右侧),破坏应用的整体体验。2、解决思路​ 为解决长文本省略与交互问题,我们的核心思路是:封装一个高可定制性、高性能的TextEllipsis自定义组件。该组件不仅提供基础的展开/收起功能,还将通过丰富的参数暴露最大程度的定制能力,确保其能灵活融入各种UI设计风格。(一) 核心功能组件化封装组件内部完整实现状态管理、条件渲染和交互逻辑,对外提供简洁的调用接口。(二) 全面的样式定制、灵活的配置选项允许使用者传入参数,自定义文本内容的字体颜色、字体大小、字重等所有Text组件支持的样式属性。同样允许自定义展开/收起操作按钮的文案及其文字样式。支持自定义收起时的最大行数、支持通过参数控制初始状态是展开还是收起(三) 智能判断集成高效的文本溢出判断逻辑,仅在文本确实需要截断时才显示操作按钮,避免不必要的渲染。同时优化测量时机,减少性能开销3、解决方案(一)封装TextEllipsis组件import { componentUtils, ComponentUtils, MeasureOptions } from "@kit.ArkUI" /** * 组件信息类型 */ interface ComponentsInfoType { width: number height: number localLeft: number localTop: number screenLeft: number screenTop: number windowTop: number windowLeft: number } @ComponentV2 export struct TextEllipsis { /** * 显示文本内容 */ @Param @Require text: string /** * 显示文本的字体大小 */ @Param textFontSize: string | number | Resource = 14 /** * 显示文本的颜色 */ @Param textColor: ResourceColor = "#000000" /** * 显示文本的字体字重 */ @Param textFontWeight: string | number | FontWeight = FontWeight.Normal /** * 行高 */ @Param lineHeight: string | number = 20 /** * 展示的行数 */ @Param rows: number = 1 /** * 是否显示操作 */ @Param showAction: boolean = false /** * 显示文本的颜色 */ @Param actionTextColor: ResourceColor = "#1989fa" /** * 显示文本的字体字重 */ @Param actionTextFontWeight: string | number | FontWeight = FontWeight.Normal /** * 展开操作文案 */ @Param expandText: string = "展开" /** * 收起操作文案 */ @Param collapseText: string = "收起" /** * 省略号内容 */ @Param omitContent: string = "…" /** * 默认是否展开 */ @Param defaultExtend: boolean = false; // @Local uniId: number = 0 @Local showText: string = "" @Local textWidth: number = 0 @Local textHeight: number = 0 @Local maxLineHeight: number = 0 @Local isExpand: boolean = false // private uiContext = this.getUIContext() aboutToAppear(): void { this.uniId = this.getUniqueId() this.formatText() this.isExpand = this.defaultExtend } @Monitor("text", "rows") formatText() { setTimeout(() => { this.textWidth = this.getComponentsInfo(this.uiContext, `text_ellipsis_${this.uniId}`).width this.textHeight = this.measureTextHeight(this.text) this.maxLineHeight = this.measureTextHeight(this.text, this.rows) if (this.textHeight > this.maxLineHeight) { this.getTextByWidth() } else { this.showText = this.text } }, 100) } getTextByWidth() { let clipText = this.text let textHeight = this.textHeight let omitText = this.omitContent let expandText = this.expandText while (textHeight > this.maxLineHeight) { clipText = clipText.substring(0, clipText.length - 1) textHeight = this.measureTextHeight(clipText + (this.textHeight > this.maxLineHeight ? omitText : "") + (this.showAction ? expandText : "")) } this.showText = clipText } /** * 获取组件信息 * @param {context} UIContext * @param {id} 组件id * */ getComponentsInfo(context: UIContext, id: string): ComponentsInfoType { let comUtils: ComponentUtils = context.getComponentUtils() let info: componentUtils.ComponentInfo = comUtils.getRectangleById(id) return { width: context.px2vp(info.size.width), height: context.px2vp(info.size.height), localLeft: context.px2vp(info.localOffset.x), localTop: context.px2vp(info.localOffset.y), screenLeft: context.px2vp(info.screenOffset.x), screenTop: context.px2vp(info.screenOffset.y), windowLeft: context.px2vp(info.windowOffset.x), windowTop: context.px2vp(info.windowOffset.y) } } /** * 测量文字尺寸 */ measureTextSize(context: UIContext, option: MeasureOptions): Size { const measureUtils = context.getMeasureUtils() const sizeOptions = measureUtils.measureTextSize(option) return { width: context.px2vp(sizeOptions.width as number), height: context.px2vp(sizeOptions.height as number) } } /** * 获取文本尺寸高度 * @param text 文本内容 * @param rows 显示的行数 * @returns */ measureTextHeight(text: string, rows?: number): number { return this.measureTextSize(this.uiContext, { textContent: text, constraintWidth: this.textWidth, fontSize: this.textFontSize, lineHeight: this.lineHeight, maxLines: rows }).height } build() { Text() { Span(this.isExpand ? this.text : this.showText) .fontSize(this.textFontSize) .fontColor(this.textColor) .fontWeight(this.textFontWeight) if (this.textHeight > this.maxLineHeight && !this.isExpand) { Span(this.omitContent) .fontSize(this.textFontSize) .fontColor(this.textColor) .fontWeight(this.textFontWeight) } if (this.showAction && this.textHeight > this.maxLineHeight) { Span(this.isExpand ? this.collapseText : this.expandText) .fontSize(this.textFontSize) .fontColor(this.actionTextColor) .fontWeight(this.actionTextFontWeight) .onClick(() => { this.isExpand = !this.isExpand }) } } .id(`text_ellipsis_${this.uniId}`) .width("100%") .lineHeight(this.lineHeight) } } (二)使用示例import { TextEllipsis} from './TextEllipsis TextEllipsis({ text: “为了确保时序正确性,建议开发者自行监听字体缩放变化,以保证测算结果的准确性。在测算裁剪后的文本时,由于某些Unicode字符(如emoji)的码位长度大于1,直接按字符串长度裁剪会导致不准确的结果。建议基于Unicode码点进行迭代处理,避免错误截断字符,确保测算结果准确。”, showAction: true, rows: 1 }) 4、方案成果总结(一) 开发效率的倍增,开发者无需再关心复杂的展开收起逻辑,只需通过一行声明式代码即可引入该功能,极大缩短了开发时间。(二) 用户体验与一致性保障,确保了整个应用程序内部,所有长文本的展开收起操作在交互逻辑、动画效果(可扩展)和视觉风格上保持高度一致,提升了产品的专业度和用户体验。(三) 极致的可定制性与灵活性,提供从文本内容、行数到文本样式乃至操作按钮文案和样式的全方位定制能力,使组件能无缝适配任何UI设计风格。还可根据需要深度定制与扩展。
  • [开发技术领域专区] 开发者技术支持-图形化验证码组件适配案例技术总结
    1.1问题说明在鸿蒙应用图形验证码功能开发中,存在几类核心问题:一是组件之间信息传递不顺畅,点击 “刷新” 按钮时,验证码无法同步更新;二是验证码的显示内容与实际记录的状态不一致,比如点击验证码图片刷新后,页面记录的验证码信息没有跟着变,导致验证功能失效;三是重复创建工具类,造成资源浪费,且全局控制变量可能引发状态混乱;四是验证码初始化或用户操作时,可能出现显示空白或状态错乱的情况。1.2原因分析(一)信息传递方式不合理:依赖全局变量实现组件间通信,未采用系统原生的状态关联机制,导致 “刷新” 操作无法触发验证码组件的内容更新与信息同步;(二)信息更新流程缺失:新验证码生成后,未将结果同步至记录变量;点击验证码图片刷新时,新内容也未被记录,造成 “显示内容” 与 “记录信息” 脱节;(三)工具复用设计缺失:生成验证码的工具未做复用处理,每次调用都需重新创建实例、初始化字符库等基础数据,额外增加资源消耗;(四)控制模块形式化设计:控制工具中的刷新方法仅简单返回参数,未关联验证码生成与信息更新的核心逻辑,属于冗余环节。1.3解决思路(一)优化组件信息传递:摒弃全局变量通信,采用系统原生的状态关联功能,实现父子组件信息实时同步与操作联动;(二)统一信息更新逻辑:在页面初始化、点击验证码图片、点击 “刷新” 按钮等所有场景中,生成新验证码后立即同步更新记录信息,确保显示内容与记录信息一致;(三)实现工具复用设计:将生成验证码的工具设计为 “单实例” 模式,避免重复创建与初始化,减少资源浪费;(四)精简冗余控制逻辑:删除无实际作用的全局控制工具,让验证码组件通过自身方法直接实现刷新功能,降低代码复杂度。1.4解决方案(1)打通组件联动与状态同步采用系统原生状态关联机制替代全局变量,实现父子组件信息双向绑定,确保状态实时同步;统一验证码生成与刷新入口,在初始化、点击验证码、点击 “刷新” 按钮时,均通过同一方法生成新内容并同步更新记录,解决 “显示与记录不一致” 问题;优化点击事件逻辑,刷新前清空旧状态并延迟生成新内容,避免空白或同步延迟。(2)优化工具类设计将验证码生成工具改造为单实例模式,通过静态方法获取唯一实例,禁止重复创建,减少基础数据(如数字字符库)重复初始化的资源消耗;保留 4 位数字、随机旋转、干扰线 / 点等核心生成逻辑,优化绘制流程,采用状态保存 / 恢复机制提升 Canvas 稳定性,确保显示清晰。(3)精简组件结构与逻辑删除无实际功能的全局控制模块,剥离组件多余依赖,让验证码组件自主管理生成与刷新逻辑,明确职责边界;精简入参(仅保留状态关联所需参数),统一字体、背景等样式配置,降低代码冗余,便于后续调整尺寸、样式等。验证码工具类代码示例:import { VerifyCodeHelper } from './VerifyCodeHelper'; export class RefreshController { refreshCode = () => { } } // 重构验证码组件,优化交互逻辑 @Component export struct ImageVerify { refreshController: RefreshController | null = null; // Canvas配置 private renderSettings: RenderingContextSettings = new RenderingContextSettings(true); canvasCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.renderSettings); // 组件尺寸与状态绑定 @State compWidth: number = 140; @State compHeight: number = 40; // 双向绑定验证码文本 @Link verifyText: string; aboutToAppear(): void { if (this.refreshController) { this.refreshController.refreshCode = () => { this.refreshCode(); } } } // 统一刷新方法(触发重新生成验证码) refreshCode() { this.verifyText = VerifyCodeHelper.getInstance() .generateVerifyCode(this.canvasCtx, this.compWidth, this.compHeight); } build() { Row() { Canvas(this.canvasCtx) .width(this.compWidth) .height(this.compHeight) .backgroundColor('#e0e0e0') .onReady(() => { // 组件初始化时生成验证码 this.refreshCode(); }) .onClick(() => { // 点击验证码刷新 this.refreshCode(); }) } .width('100%') .height('100%') } } 验证码组件代码示例:// 单例工具类,重构原绘制逻辑 export class VerifyCodeHelper { private static singleInstance: VerifyCodeHelper; // 重构原数字字符库配置 private digitSource: string = "0,1,2,3,4,5,6,7,8,9"; private digitArray: string[] = this.digitSource.split(","); private arrayLength: number = this.digitArray.length; // 私有构造,禁止外部实例化 private constructor() { } // 获取单例实例 public static getInstance(): VerifyCodeHelper { if (!VerifyCodeHelper.singleInstance) { VerifyCodeHelper.singleInstance = new VerifyCodeHelper(); } return VerifyCodeHelper.singleInstance; } // 生成图形验证码(重构原drawImgCode方法) generateVerifyCode( ctx: CanvasRenderingContext2D, width: number = 100, height: number = 40 ): string { let verifyText: string = ""; // 清空画布 ctx.clearRect(0, 0, width, height); // 绘制4位随机数字(保留原旋转、位置逻辑) for (let i = 0; i < 4; i++) { const randomIdx = Math.floor(Math.random() * this.arrayLength); const rotateRad = Math.random() - 0.5; // 随机弧度(-0.5~0.5) const currentDigit = this.digitArray[randomIdx]; verifyText += currentDigit.toLowerCase(); // 计算文字位置(参考原坐标逻辑) const textX = 10 + i * 20; const textY = height / 2 + Math.random() * 8; // 文字绘制与旋转(重构原绘制流程) ctx.font = "20vp sans-serif"; ctx.save(); ctx.translate(textX, textY); ctx.rotate(rotateRad); ctx.fillStyle = this.getRandomRgbColor(); ctx.fillText(currentDigit, 0, 0); ctx.restore(); } // 绘制干扰线(5条,保留原数量) for (let i = 0; i <= 5; i++) { ctx.strokeStyle = this.getRandomRgbColor(); ctx.beginPath(); ctx.moveTo(Math.random() * width, Math.random() * height); ctx.lineTo(Math.random() * width, Math.random() * height); ctx.stroke(); } // 绘制干扰点(20个,保留原数量) for (let i = 0; i <= 20; i++) { ctx.strokeStyle = this.getRandomRgbColor(); ctx.beginPath(); const dotX = Math.random() * width; const dotY = Math.random() * height; ctx.moveTo(dotX, dotY); ctx.lineTo(dotX + 1, dotY + 1); ctx.stroke(); } return verifyText; } // 生成随机RGB颜色(重构原getColor方法) private getRandomRgbColor(): string { const red = Math.floor(Math.random() * 256); const green = Math.floor(Math.random() * 256); const blue = Math.floor(Math.random() * 256); return `rgb(${red},${green},${blue})`; } } 演示代码示例:import { ImageVerify, RefreshController } from './ImageVerify'; @Entry @Component struct Index { // 验证码状态(与子组件双向绑定) @State currentVerifyText: string = ''; refreshController: RefreshController = new RefreshController(); build() { Column() { Row() { // 验证码输入框 TextInput({ placeholder: '请输入右侧验证码' }) .layoutWeight(1) .padding(10) .border({ width: 1, color: '#dddddd', radius: 6 }) .margin({ right: 12 }); // 验证码组件(双向绑定状态) ImageVerify({ verifyText: $currentVerifyText, refreshController: this.refreshController }) .width(140) .height(40); // 刷新按钮 Text('刷新') .fontSize(16) .fontWeight(FontWeight.Medium) .padding({ left: 14, right: 14, top: 10, bottom: 10 }) .backgroundColor('#f0f0f0') .borderRadius(6) .margin({ left: 12 }) .onClick(() => { this.refreshController.refreshCode(); }); } // 验证按钮(扩展功能,用于测试验证码匹配) Text('验证') .fontSize(18) .fontWeight(FontWeight.Bold) .padding(12) .backgroundColor('#007aff') .borderRadius(8) .margin({ top: 30 }) .onClick(() => { // 此处可扩展验证码匹配逻辑 console.log('当前验证码:', this.currentVerifyText); }) } .height('100%') .width('100%') .padding(30) .justifyContent(FlexAlign.Center); } } 1.5方案成果总结(一)功能稳定性显著提升:组件间交互顺畅,点击刷新按钮或验证码图片均可实时更新内容,显示内容与记录信息完全一致,验证功能稳定可用;(二)运行效率大幅优化:工具复用设计避免了重复创建与初始化,资源消耗明显减少,组件刷新响应速度显著提升;(三)代码可维护性增强:移除全局变量与冗余控制逻辑,采用系统原生机制管理信息,代码结构更简洁,后续调整验证码长度、样式等操作更便捷;(四)用户体验持续优化:验证码刷新无延迟、无空白现象,操作流程符合应用使用习惯,用户操作流畅度与使用体验全面提升。
  • [技术干货] 开发者技术支持-App最小化功能工具类技术方案总结
    1、关键技术难点总结1.1 问题说明​ 在HarmonyOS应用开发中,App最小化功能对于提升用户体验具有重要作用,特别是在媒体播放、即时通讯、后台任务处理等使用场景中。然而,原生窗口管理API的复杂性以及多样化的异常处理需求给开发者带来了显著的技术挑战,具体体现在以下方面:(一)API接口调用的复杂度与可靠性问题使用HarmonyOS窗口管理API(如window.getLastWindow()、WindowClass.minimize())需要开发者深入理解复杂的调用流程和异常处理机制。在直接调用原生API时,开发者面临以下挑战:需要准确获取窗口实例,并妥善处理窗口不存在、权限缺失等异常状况;必须正确理解异步回调机制,完整处理minimize()方法的成功与失败状态;需针对不同设备类型和系统版本进行兼容性适配,防止API调用失败。(二)错误处理逻辑的分散与标准化不足需要针对各类错误代码(权限异常、窗口状态异常、系统资源不足等)设计专门的应对方案;需要建立重试机制以应对临时性故障,但重试策略缺乏统一规范,实现方式各不相同;需要妥善处理异步操作的超时和取消场景,防止出现内存泄漏和状态不一致问题。(三)开发效率与代码维护的挑战每次实现最小化功能都需要重复编写窗口获取和错误处理代码,开发效率低下;项目不同模块的最小化实现存在差异,导致功能行为不一致和维护困难;缺乏标准化的配置和回调机制,难以实现统一的用户反馈和业务逻辑集成。1.2 原因分析(一)原生API的基础架构定位HarmonyOS窗口管理API的设计目标是为开发者提供完整的窗口控制能力,其设计理念更注重"功能完整性"而非"使用便捷性"。导致API具有以下特点:接口粒度较细,需要开发者组合多个API调用才能完成完整的最小化流程;错误信息详细但处理复杂,要求开发者具备深入的系统知识才能正确应对;缺乏高级封装,无法直接满足"一键最小化"的简化需求。(二)应用场景的多元化特征媒体类应用需要在最小化后维持播放状态,要求最小化操作快速稳定;通讯类应用需要在最小化时保持连接状态,要求错误处理机制完善;工具类应用可能需要定时自动最小化,要求支持灵活的触发机制。(三)开发团队的技术能力差异经验丰富的开发者可能实现相对完善的错误处理,但代码复杂度较高;经验较少的开发者可能忽略边界情况,导致应用在特定场景下出现异常行为;缺乏统一的最佳实践指导,团队内部实现方式不一致。2、解决思路(一)工具化封装:构建统一的最小化工具基于"简化使用、统一标准"的设计理念,封装MinimizeAppUtil工具类,整合窗口获取、最小化执行、错误处理等核心功能:封装复杂的API调用序列,提供"一行代码实现最小化"的简洁接口;内置完善的错误处理和重试机制,自动应对常见的失败场景;支持灵活的配置选项,适配不同应用场景的个性化需求。(二)稳定性提升:优化错误恢复与状态管理实现智能重试策略,根据错误类型自动调整重试间隔和次数;提供完整的回调机制,支持成功、失败、重试等状态的业务逻辑集成;采用单例模式确保全局状态一致,避免多实例导致的资源竞争问题。3、解决方案(一)App最小化工具类(MinimizeAppUtil)通过封装窗口管理API调用逻辑,结合状态管理与重试机制实现稳定的最小化功能,支持多场景适配:import { BusinessError } from "@kit.BasicServicesKit"; import { window } from "@kit.ArkUI"; export interface MinimizeConfig { maxRetries?: number; // 最大重试次数,默认3次 retryDelay?: number; // 重试间隔,默认1000ms enableLogging?: boolean; // 是否启用日志,默认true onSuccess?: () => void; // 成功回调 onError?: (error: Error) => void; // 错误回调 } export class MinimizeAppUtil { // 单例实例(确保全局唯一,避免状态冲突) private static instance: MinimizeAppUtil | null = null; private context: Context; private config: MinimizeConfig; private constructor(context: Context, config?: MinimizeConfig) { this.context = context; if(config){ this.config = config }else { this.config = { maxRetries:3, retryDelay:1000, enableLogging:true } } } // 单例模式:确保全局实例唯一,避免资源竞争 static getInstance(context: Context, config?: MinimizeConfig): MinimizeAppUtil { if (!MinimizeAppUtil.instance) { MinimizeAppUtil.instance = new MinimizeAppUtil(context, config); } return MinimizeAppUtil.instance; } /** * 主要的最小化方法:带重试机制的智能最小化 */ async minimize(): Promise<boolean> { for (let attempt = 1; attempt <= this.config.maxRetries!; attempt++) { try { const success = await this.performMinimize(); if (success) { this.log(`最小化成功 (第${attempt}次尝试)`); this.config.onSuccess?.(); return true; } } catch (error) { this.log(`最小化失败 (第${attempt}次尝试): ${error}`); if (attempt === this.config.maxRetries) { const finalError = new Error(`最小化失败,已重试${this.config.maxRetries}次`); this.config.onError?.(finalError); return false; } // 等待后重试 await this.delay(this.config.retryDelay!); } } return false; } /** * 执行实际的最小化操作 */ private async performMinimize(): Promise<boolean> { return new Promise(async (resolve, reject) => { try { const windowClass: window.Window = await window.getLastWindow(this.context); windowClass.minimize((err: BusinessError) => { if (err.code) { reject(new Error(`窗口最小化失败: ${err.message} (错误码: ${err.code})`)); } else { resolve(true); } }); } catch (error) { reject(new Error(`获取窗口实例失败: ${error}`)); } }); } // 延时工具方法 private delay(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); } // 日志输出方法 private log(message: string): void { if (this.config.enableLogging) { console.log(`[MinimizeAppUtil] ${message}`); } } } (二)核心使用场景示例工具类支持"一键调用"与"配置化调用"两种模式,直接调用对应方法即可实现App最小化,示例代码如下:基础场景调用// 一行代码实现最小化 MinimizeAppUtil.getInstance(getContext()).minimize(); 自定义配置调用// 自定义配置 const config: MinimizeConfig = { maxRetries: 5, retryDelay: 2000, enableLogging: true, onSuccess: () => { console.log('应用已成功最小化'); // 执行成功后的业务逻辑 }, onError: (error) => { console.error('最小化失败:', error.message); // 执行错误处理逻辑 } }; // 使用自定义配置 MinimizeAppUtil.getInstance(getContext(), config).minimize(); (三)关键交互流程:用户触发最小化操作(如点击最小化按钮、手势操作、系统事件);工具类自动获取当前窗口实例,执行窗口最小化API调用;若调用成功,则执行成功回调并返回true;若调用失败,则根据配置进行重试;重试过程中自动处理各种错误情况(如权限不足、窗口状态异常等);达到最大重试次数后仍失败,则执行错误回调并返回false,完成单次最小化操作闭环。4、方案成果总结(一)功能层面:通过工具类统一实现App最小化能力,封装复杂的窗口管理API调用,内置智能重试机制应对各种异常情况,有效保障应用稳定运行。(二)开发层面:工具类提供”一行代码”的简洁调用方式,开发者无需掌握复杂的窗口管理API细节;同时支持灵活的配置选项,适配不同应用场景需求,减少代码重复与维护成本。(三)用户层面:通过稳定可靠的最小化功能,提升用户在多任务切换场景下的操作体验;结合完善的错误处理机制,避免最小化失败导致的应用异常,优化整体使用体验。
  • [技术干货] 开发者技术支持-全局加载动画技术方案总结
    1、关键技术难点总结1.1 问题说明(一)不同页面加载动画样式不统一:在鸿蒙应用开发中,存在不同页面加载动画样式不统一的问题。当用户在不同页面触发加载操作时,会看到风格不同的加载动画,影响用户体验的一致性。有些页面使用简单的旋转图标,有些页面使用进度条,还有些页面甚至没有加载提示,这种不一致性源于缺乏统一的加载动画规范和组件复用机制。(二)重复编写加载动画逻辑代码:在鸿蒙应用开发中,存在重复编写加载动画逻辑代码的问题。每个需要加载动画的页面都需要单独实现显示/隐藏逻辑、状态管理、动画效果等,导致大量重复代码。当需要修改加载动画样式或行为时,需要在多个地方进行修改,维护成本高,这种重复性工作源于缺乏可复用的加载动画组件和统一的调用接口。(三)动画状态管理复杂,容易出现内存泄漏:在鸿蒙应用开发中,存在动画状态管理复杂且容易出现内存泄漏的问题。当页面频繁显示/隐藏加载动画或在页面生命周期变化时,容易出现动画未正确停止、监听器未及时移除等情况,导致内存泄漏和性能问题。特别是在复杂页面中,多个组件可能同时使用加载动画,状态同步变得困难,这种复杂性源于缺乏统一的状态管理机制和生命周期处理规范。(四)全局动画调用不够便捷:在鸿蒙应用开发中,存在全局动画调用不够便捷的问题。当前实现中,要在页面中使用加载动画,需要在每个页面手动导入组件、初始化状态、编写显示/隐藏逻辑,操作繁琐且容易出错。开发者希望能通过简单的一行代码调用加载动画,而不需要关心底层实现细节,这种不便源于缺乏封装良好的工具类和全局状态管理机制。1.2 原因分析(一)缺乏统一的加载动画组件规范:在鸿蒙应用开发中,由于缺乏统一的加载动画组件规范,导致不同开发者实现的加载动画样式各异。没有制定统一的设计规范和组件接口标准,使得加载动画在不同页面呈现出不同的视觉效果和交互方式,这种规范缺失源于团队缺乏组件化设计的统一标准和设计指南。(二)动画与业务逻辑耦合度高:在鸿蒙应用开发中,由于动画与业务逻辑耦合度高,导致代码复用性差。加载动画的显示/隐藏逻辑往往与具体的业务逻辑混杂在一起,使得动画组件无法独立复用。当业务逻辑发生变化时,可能需要同时修改动画相关的代码,这种高耦合性源于没有将动画组件与业务逻辑进行有效解耦。(三)没有建立全局状态管理机制:在鸿蒙应用开发中,由于没有建立全局状态管理机制,导致动画状态在不同页面间无法共享。每个页面都需要独立管理加载动画的状态,无法实现全局统一控制。当需要在不同页面间协调加载状态时,缺乏有效的通信机制,这种机制缺失源于没有设计统一的状态管理模式和跨组件通信方案。(四)缺少标准的加载动画调用接口:在鸿蒙应用开发中,由于缺少标准的加载动画调用接口,导致开发者使用不便。没有提供简单易用的API来控制加载动画的显示/隐藏,使得开发者需要深入了解动画组件的内部实现才能正确使用,这种接口缺失源于没有从开发者使用角度设计封装良好的工具类和调用方法。2、解决思路通过自定义组件+全局状态管理的方式,设计一个统一的加载动画解决方案:创建可复用的自定义加载组件使用全局状态管理动画显示/隐藏封装统一的调用接口支持自定义动画样式和参数3、解决方案步骤1:创建全局状态管理// GlobalLoadingManager.ets export class GlobalLoadingManager { private static instance: GlobalLoadingManager = new GlobalLoadingManager(); private loadingState: boolean = false; private loadingText: string = '加载中...'; private listeners: Array<(show: boolean, text: string) => void> = []; public static getInstance(): GlobalLoadingManager { return GlobalLoadingManager.instance; } // 显示加载动画 showLoading(text: string = '加载中...'): void { this.loadingState = true; this.loadingText = text; this.notifyListeners(); } // 隐藏加载动画 hideLoading(): void { this.loadingState = false; this.notifyListeners(); } // 注册状态监听 addListener(listener: (show: boolean, text: string) => void): void { this.listeners.push(listener); } // 移除监听 removeListener(listener: (show: boolean, text: string) => void): void { const index = this.listeners.indexOf(listener); if (index > -1) { this.listeners.splice(index, 1); } } private notifyListeners(): void { this.listeners.forEach(listener => { listener(this.loadingState, this.loadingText); }); } } 步骤2:创建自定义加载动画组件// CustomLoading.ets import { GlobalLoadingManager } from "./GlobalLoadingManager"; @Component export struct CustomLoading { @State private isShowing: boolean = false; @State private loadingText: string = '加载中...'; @State private rotateAngle: number = 0; aboutToAppear(): void { GlobalLoadingManager.getInstance().addListener(this.onLoadingStateChange.bind(this)); } aboutToDisappear(): void { GlobalLoadingManager.getInstance().removeListener(this.onLoadingStateChange.bind(this)); } private onLoadingStateChange(show: boolean, text: string): void { this.isShowing = show; this.loadingText = text; if (show) { this.startRotationAnimation(); } } private startRotationAnimation(): void { this.rotateAngle = 0; const animation = animateTo({ duration: 1000, tempo: 1, curve: Curve.Linear, iterations: -1, // 无限循环 onFinish: () => { console.info('Animation finished'); } }, () => { this.rotateAngle = 360; }); } build() { if (this.isShowing) { Stack({ alignContent: Alignment.Center }) { // 半透明背景 Column() { // 空列用于背景 } .width('100%') .height('100%') .backgroundColor('#000000') .opacity(0.3) // 加载动画内容 Column() { // 旋转图标 Row() { Image($r('app.media.ic_loading')) .width(40) .height(40) .rotate({ angle: this.rotateAngle }) .margin({left: 20}) } .width(80) .height(80) .backgroundColor('#FFFFFF') .borderRadius(10) .shadow({ radius: 10, color: '#33000000', offsetX: 0, offsetY: 2 }) .alignItems(VerticalAlign.Center) // 加载文字 Text(this.loadingText) .fontSize(14) .fontColor('#FFFFFF') .margin({ top: 10 }) } .alignItems(HorizontalAlign.Center) } .width('100%') .height('100%') .position({ x: 0, y: 0 }) } } } 步骤3:创建工具类封装调用方法// LoadingUtils.ets import { GlobalLoadingManager } from "./GlobalLoadingManager"; export class LoadingUtils { // 显示加载动画 public static showLoading(text?: string): void { GlobalLoadingManager.getInstance().showLoading(text); } // 隐藏加载动画 public static hideLoading(): void { GlobalLoadingManager.getInstance().hideLoading(); } // 带自动隐藏的加载 public static async showLoadingWithAutoHide(duration: number = 3000, text?: string): Promise<void> { LoadingUtils.showLoading(text); setTimeout(() => { LoadingUtils.hideLoading(); }, duration); } // 网络请求包装器 public static async withLoading<T>(promise: Promise<T>, text?: string): Promise<T | null> { LoadingUtils.showLoading(text); try { const result = await promise; LoadingUtils.hideLoading(); return result; } catch (error) { LoadingUtils.hideLoading(); console.error('error: ' + error) return null } } } 步骤4:在主页面使用全局加载组件// Index.ets import { CustomLoading } from './CustomLoading'; import { LoadingUtils } from './LoadingUtils'; @Entry @Component struct Index { build() { Stack() { // 主页面内容 Column() { Text('全局加载动画演示') .fontSize(20) .fontWeight(FontWeight.Bold) Button('显示加载动画') .width('60%') .margin({ top: 20 }) .onClick(() => { LoadingUtils.showLoading( '数据加载中...'); // 3秒后自动隐藏 setTimeout(() => { LoadingUtils.hideLoading(); }, 3000); }) Button('模拟网络请求') .width('60%') .margin({ top: 20 }) .onClick(() => { this.mockNetworkRequest(); }) Button('自定义文字') .width('60%') .margin({ top: 20 }) .onClick(() => { LoadingUtils.showLoading('正在处理,请稍候...'); setTimeout(() => { LoadingUtils.hideLoading(); }, 2000); }) // 全局加载组件 CustomLoading() } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } } // 模拟网络请求 private async mockNetworkRequest(): Promise<void> { await LoadingUtils.withLoading( new Promise<void>((resolve) => { setTimeout(() => { console.info('网络请求完成'); resolve(); }, 2500); }), '请求数据中...' ); } } 4、方案成果总结(一)统一性:整个应用使用统一的加载动画样式。通过创建标准化的组件和全局状态管理机制,确保了所有页面使用一致的加载动画样式,提升了用户体验的一致性。无论用户在哪个页面触发加载操作,都能看到相同风格的加载动画,这种统一性源于组件化设计和全局状态管理的结合。(二)便捷性:一行代码即可调用加载动画。通过封装工具类,开发者只需调用LoadingUtils.showLoading()即可显示加载动画,调用LoadingUtils.hideLoading()即可隐藏动画,极大地简化了使用流程。这种便捷性使得开发者能够专注于业务逻辑实现,而无需关心加载动画的底层实现细节。(三)灵活性:支持自定义文字、持续时间。方案支持通过参数传递自定义加载文字,未来可扩展支持不同类型的动画效果和持续时间设置,满足不同场景的需求。开发者可以根据具体业务场景灵活调整加载提示文字,这种灵活性源于参数化设计和可扩展的架构。(四)性能优化:避免重复创建动画对象,减少内存占用。通过全局单例模式管理加载状态和监听器,避免了在每个页面重复创建动画对象和监听器,有效减少了内存占用。同时,通过合理的生命周期管理,确保动画在不需要时能够正确停止和清理,这种优化源于对内存管理和生命周期的深入理解。
  • [开发技术领域专区] 开发者技术支持-防重复点击适配案例技术总结
    1.1问题说明在鸿蒙应用中,按钮、列表项、表单提交等控件被用户快速连续点击时,易触发多次目标函数执行。这会导致接口重复请求、页面重复跳转、状态修改错乱等问题,进而引发数据不一致、界面卡顿、资源浪费等异常,影响应用稳定性和用户体验。1.2原因分析(一)用户操作习惯:部分用户存在快速连续点击控件的行为,尤其是在网络响应较慢或界面反馈不及时的场景下。(二)缺乏统一控制:防重复点击逻辑分散在各业务模块,未形成全局统一的解决方案,维护成本高且易遗漏。(三)系统事件机制:鸿蒙系统事件分发响应效率高,未做限制时,连续点击会快速触发绑定的函数。1.3解决思路(一)封装统一工具类:将节流、防抖核心逻辑集中封装,提供标准化调用接口,降低业务代码耦合度。(二)双模式节流适配:支持 “立即执行” 和 “延迟执行” 两种节流模式,满足不同业务场景(如立即反馈操作结果、等待接口响应后执行)。(三)场景化防抖设计:通过唯一 ID 区分不同防抖场景,避免多控件、多业务的防抖逻辑相互干扰。(四)单例模式保障:采用单例模式确保工具类全局唯一,避免多实例导致的状态冲突和控制失效。(五)轻量无依赖实现:内部自主管理定时器和状态,不依赖外部缓存工具,降低鸿蒙应用适配成本。1.4解决方案基于鸿蒙应用运行特性,设计单例模式工具类,集中实现节流、防抖逻辑,适配多场景防重复需求:单例模式构建:通过静态方法 getInstance () 创建全局唯一实例,避免多实例引发的状态冲突,保障防重复控制的一致性。节流功能实现:支持两种执行模式:立即执行(先执行目标函数,再锁定 wait 时长)和延迟执行(锁定 wait 时长后,执行目标函数)。内部维护 throttleFlag 状态标识和 throttleTimer 定时器,通过锁定期控制函数触发频率,默认间隔 1000ms。防抖功能实现:采用 Map 结构(debounceTimerMap)管理不同场景的定时器,通过 clickId 作为唯一标识区分场景。重复触发时清除当前场景的旧定时器,重置延迟时长,确保只有最后一次触发在 wait 时长后执行,默认间隔 1000ms。轻量无依赖设计:不依赖外部缓存组件,通过内部变量和集合管理状态与定时器,降低鸿蒙应用适配复杂度。代码示例:export class ThrottleDebounceUtil { // 节流状态标识(静态属性需保证唯一性) private throttleFlag = false; // 节流定时器(统一管理,避免分散) private throttleTimer: number | null = null; // 防抖:使用 Map 管理不同 clickId 的定时器(替代 CacheUtil,避免依赖外部缓存) private debounceTimerMap = new Map<string, number>(); // 防抖默认 ID(保持原有逻辑兼容) private defaultDebounceId = new Date().toDateString(); // 单例模式:确保全局实例唯一,避免状态冲突 private static instance: ThrottleDebounceUtil | null = null; static getInstance() { if (ThrottleDebounceUtil.instance) { return ThrottleDebounceUtil.instance; } ThrottleDebounceUtil.instance = new ThrottleDebounceUtil(); return ThrottleDebounceUtil.instance; } /** * 节流函数:控制函数执行频率 * @param func 要执行的函数 * @param wait 等待时长(毫秒),默认 1000ms * @param immediate 是否立即执行(true: 先执行再等待;false: 等待结束后执行) */ throttle( func: () => void, wait: number = 1000, immediate: boolean = true ): void { // 立即执行模式:需保证当前无执行锁 if (immediate) { if (!this.throttleFlag) { this.throttleFlag = true; func(); // 立即执行目标函数 // 等待时长后释放锁 this.throttleTimer = setTimeout(() => { this.throttleFlag = false; this.throttleTimer && clearTimeout(this.throttleTimer); }, wait); } } // 延迟执行模式:需保证当前无定时器 else { if (!this.throttleTimer) { this.throttleTimer = setTimeout(() => { func(); // 等待结束后执行 this.throttleTimer && clearTimeout(this.throttleTimer); this.throttleTimer = null; }, wait); } } } /** * 防抖函数:延迟执行函数,重复触发则重置延迟 * @param func 要执行的函数 * @param wait 等待时长(毫秒),默认 1000ms * @param clickId 用于区分不同防抖场景的 ID,默认使用日期字符串 */ debounce( func: () => void, wait: number = 1000, clickId: string = this.defaultDebounceId ): void { // 清除之前的定时器 const existingTimer = this.debounceTimerMap.get(clickId); existingTimer && clearTimeout(existingTimer); // 新建定时器 const newTimer = setTimeout(() => { func(); // 延迟执行目标函数 this.debounceTimerMap.delete(clickId); // 执行后清除记录 }, wait); // 缓存新定时器 this.debounceTimerMap.set(clickId, newTimer); } } 1.5方案成果总结(一)解决核心问题:有效拦截高频重复点击,彻底避免接口重复请求、状态错乱等异常,保障应用功能稳定性。(二)提升应用性能:减少不必要的函数执行和资源占用,降低界面卡顿概率,优化鸿蒙应用运行流畅度。(三)适配多业务场景:节流双模式、防抖场景化设计,覆盖按钮点击、表单提交、列表滑动等各类高频操作场景。(四)降低开发成本:统一工具类替代分散的业务内控制逻辑,减少重复编码,提升开发效率,便于后续维护迭代。(五)鸿蒙适配友好:单例模式适配鸿蒙系统运行机制,轻量无依赖设计降低集成门槛,可快速接入各类鸿蒙应用。
  • [技术干货] 开发者技术支持-文本转语音组件技术方案总结
    1、关键技术难点总结1.1 问题说明在HarmonyOS平台上实现文本转语音(textToSpeech)功能面临以下几个关键技术痛点:textToSpeech的创建需要传入多个参数,包括语言、发音人、在线/离线模式等,参数配置不当容易导致引擎创建失败。textToSpeech播报涉及多个异步操作,包括引擎初始化、文本播报、状态监听等,需要合理管理Promise和回调函数。textToSpeech引擎占用系统资源,在不使用时需要正确关闭和释放,否则可能导致内存泄漏或资源浪费。在UI界面上需要准确反映textToSpeech引擎的工作状态(空闲/播报中),如完成播报回调onComplete有两次回调过程1.2 原因分析API复杂性:HarmonyOS的textToSpeech API较为底层,直接使用需要处理大量细节,开发者学习成本高。异步编程模型:textToSpeech操作本质上是异步的,涉及到回调函数和Promise的嵌套使用,容易出现状态管理混乱。播放回调存在多次:完成播报回调onComplete有两次回调过程,一次语言合成回调,一次语言播报完成回调,在UI界面上的状态需要多情况处理2、解决思路封装核心API:将鸿蒙原生textToSpeech API进行封装,提供简洁易用的接口,隐藏底层实现细节。统一异步处理:采用Promise方式统一封装异步操作,提供async/await风格的调用接口,简化使用流程。完善状态管理:通过状态标志位和事件回调机制,实时跟踪和同步textToSpeech引擎的工作状态。建立错误处理体系:针对不同类型的错误建立分类处理机制,提供详细的错误信息反馈。优化资源管理:提供标准的资源初始化和释放接口,确保系统资源得到合理利用。3、解决方案3.1 核心组件设计设计[TtsComponent]组件,包含以下核心功能:参数接口定义:TtsOptions接口定义了文本、速度、音量、语言等配置参数TtsCallbacks接口定义了开始、完成、停止、错误等回调函数/** * 文本转语音组件参数接口 */ export interface TtsOptions { /** * 要播报的文本内容 */ text: string; /** * 播放速度,范围通常为0.5-2.0,默认为1.0 */ speed?: number; /** * 播放音量,范围通常为0-100,默认为50 */ volume?: number; /** * 语种,如'zh-CN'(中文)、'en-US'(英文)等,默认为'zh-CN' */ language?: string; /** * 音调,默认为1.0 */ pitch?: number; /** * 发音人,默认为0 */ person?: number; /** * 是否在线合成,默认为true */ online?: boolean; } /** * 文本转语音组件事件回调接口 */ export interface TtsCallbacks { /** * 开始播报回调 */ onStart?: (requestId: string, response?: textToSpeech.CompleteResponse) => void; /** * 播报完成回调 */ onComplete?: (requestId: string, response?: textToSpeech.CompleteResponse) => void; /** * 播报停止回调 */ onStop?: (requestId: string, response?: textToSpeech.CompleteResponse) => void; /** * 播报出错回调 */ onError?: (requestId: string, errorCode: number, errorMessage: string) => void; } 核心方法实现:init()方法负责初始化TTS引擎speak()方法执行文本播报stop()方法停止播报shutdown()方法关闭引擎isBusy()方法检查引擎状态/** * 初始化TTS引擎 * @param options 初始化参数 * @returns Promise<void> */ public async init(options?: TtsOptions): Promise<void> { return new Promise((resolve, reject) => { if (this.engineCreated) { resolve(); return; } const lang = options?.language || 'zh-CN'; const person = options?.person !== undefined ? options.person : 0; const online = options?.online !== undefined ? (options.online ? 1 : 0) : 1; // 设置创建引擎参数 let extraParam: Record<string, Object> = { "style": 'interaction-broadcast', "locate": lang.split('-')[1] || 'CN', "name": 'TtsEngine' }; let initParamsInfo: textToSpeech.CreateEngineParams = { language: lang, person: person, online: online, extraParams: extraParam }; try { // 调用createEngine方法 textToSpeech.createEngine(initParamsInfo, (err: BusinessError, textToSpeechEngine: textToSpeech.TextToSpeechEngine) => { if (!err) { console.log('TTS引擎创建成功'); // 接收创建引擎的实例 this.ttsEngine = textToSpeechEngine; this.engineCreated = true; resolve(); } else { console.error(`TTS引擎创建失败, 错误码: ${err.code}, 错误信息: ${err.message}`); reject(new Error(`TTS引擎创建失败: ${err.message}`)); } }); } catch (error) { const businessError = error as BusinessError; const message = businessError.message; const code = businessError.code; console.error(`TTS引擎创建异常, 错误码: ${code}, 错误信息: ${message}`); reject(new Error(`TTS引擎创建异常: ${message}`)); } }); } /** * 播报文本 * @param options 播报参数 * @param callbacks 事件回调 * @returns Promise<void> */ public async speak(options: TtsOptions, callbacks?: TtsCallbacks): Promise<void> { if (!this.engineCreated || !this.ttsEngine) { await this.init(options) } return new Promise((resolve, reject) => { // 设置播报相关参数 const speed = options.speed !== undefined ? options.speed : 1.0; const volume = options.volume !== undefined ? options.volume : 50; const language = options.language || 'zh-CN'; const pitch = options.pitch !== undefined ? options.pitch : 1.0; const person = options.person !== undefined ? options.person : 0; const online = options.online !== undefined ? (options.online ? 1 : 0) : 1; let extraParam: Record<string, Object> = { "queueMode": 0, "speed": speed, "volume": volume / 50, // 调整音量参数范围 "pitch": pitch, "languageContext": language, "audioType": "pcm", "soundChannel": 3, "playType": 1 }; // 生成唯一的请求ID const requestId = 'tts_' + Date.now(); this.currentRequestId = requestId; let speakParams: textToSpeech.SpeakParams = { requestId: requestId, extraParams: extraParam }; // 创建回调对象 let speakListener: textToSpeech.SpeakListener = { // 开始播报回调 onStart: (reqId: string, response: textToSpeech.StartResponse) => { console.info(`TTS开始播报, 请求ID: ${reqId}`); if (callbacks?.onStart) { callbacks.onStart(reqId); } }, // 完成播报回调 onComplete: (reqId: string, response: textToSpeech.CompleteResponse) => { console.info(`TTS播报完成, 请求ID: ${reqId}`); if (callbacks?.onComplete) { callbacks.onComplete(reqId, response); } resolve(); }, // 停止播报完成回调 onStop: (reqId: string, response: textToSpeech.StopResponse) => { console.info(`TTS播报停止, 请求ID: ${reqId}`); if (callbacks?.onStop) { callbacks.onStop(reqId); } resolve(); }, // 返回音频流(如果需要处理音频数据) onData: (reqId: string, audioData: ArrayBuffer, response: textToSpeech.SynthesisResponse) => { console.info(`TTS音频数据, 请求ID: ${reqId}, 序列号: ${response.sequence}`); }, // 错误回调 onError: (reqId: string, errorCode: number, errorMessage: string) => { console.error(`TTS播报出错, 请求ID: ${reqId}, 错误码: ${errorCode}, 错误信息: ${errorMessage}`); if (errorCode === 1002300007) { this.engineCreated = false; } if (callbacks?.onError) { callbacks.onError(reqId, errorCode, errorMessage); } reject(new Error(`TTS播报出错: ${errorMessage}`)); } }; // 设置回调 this.ttsEngine?.setListener(speakListener); try { // 调用speak播报方法 this.ttsEngine?.speak(options.text, speakParams); } catch (error) { const businessError = error as BusinessError; const message = businessError.message; const code = businessError.code; console.error(`TTS播报异常, 错误码: ${code}, 错误信息: ${message}`); reject(new Error(`TTS播报异常: ${message}`)); } }); } /** * 停止播报 */ public stop(): void { if (this.ttsEngine && this.engineCreated) { try { const isBusy: boolean = this.ttsEngine.isBusy(); if (isBusy) { this.ttsEngine.stop(); } } catch (err) { console.error('停止播报失败:', err); } } } /** * 关闭TTS引擎 */ public shutdown(): void { if (this.ttsEngine && this.engineCreated) { try { this.ttsEngine.shutdown(); this.engineCreated = false; this.ttsEngine = null; console.log('TTS引擎已关闭'); } catch (err) { console.error('关闭TTS引擎失败:', err); } } } /** * 检查TTS引擎是否正在播报 * @returns boolean */ public isBusy(): boolean { if (this.ttsEngine && this.engineCreated) { try { return this.ttsEngine.isBusy(); } catch (err) { console.error('检查TTS引擎状态失败:', err); return false; } } return false; } 3.2 异步处理优化使用Promise封装所有异步操作合理处理异步操作的异常情况,确保程序稳定性提供完整的事件回调机制,满足不同使用场景需求3.3 错误处理机制对引擎创建失败、参数错误等情况进行分类处理提供详细的错误码和错误信息反馈3.4 资源管理策略提供显式的资源初始化和释放接口在组件销毁时自动清理资源防止重复初始化和重复释放等问题if (!this.engineCreated || !this.ttsEngine) { await this.init(options) } 4、方案成果总结将复杂的HarmonyOS textToSpeech API封装为标准化组件,极大降低了使用门槛;提供了清晰的参数配置接口和事件回调接口,满足不同业务场景需求;建立了完整的错误处理机制,提高了组件的稳定性和可靠性;实现了合理的资源初始化和释放机制。开发者可以直接使用封装好的组件,无需深入了解底层API细节,显著提升开发效率,增强了应用的交互体验;通过组件化封装,提高了代码的复用性和可维护性。支持多种参数配置,可以根据具体需求调整播报效果,如调整播报语速、音量。
  • [开发技术领域专区] 开发者技术支持-语音转文字适配案例技术总结
    1.1问题说明在鸿蒙系统上开发语音转文字应用时,多个影响使用体验的关键问题会直接干扰应用正常使用,具体如下:音频文件转写常出数据断档:读取音频文件来转文字时,数据会分成一段段发送给识别功能,过程中很容易出现数据传递中断的情况。而且文件读取完成后,它占用的设备资源没法及时释放,不仅可能导致转写出来的文字断断续续、残缺不全,严重时还会直接让识别功能卡住没法运行。语音识别核心功能操作混乱:负责语音转文字的核心功能模块,从开启、启动识别,到停止识别、彻底关闭,整个流程没有固定规范。实际使用中常出现重复开启该模块的情况,用完后相关资源也清理不干净;有时一边录音转文字,一边尝试读取音频文件转写,两个操作还会互相干扰,导致两者都没法正常工作。状态显示和消息提示混乱不准:应用界面上显示的 “功能就绪”“正在录音”“正在处理文件” 等状态,经常和实际情况不符,比如已经停止录音了,界面还一直显示 “正在录音”。另外,转写出来的文字、操作出错的提示信息,要么迟迟不显示,要么会和其他操作的消息混在一起,用户根本分不清哪条信息对应哪次操作。适配能力不足,多场景难兼容:不同来源的音频文件格式不一样,比如有的录音采样清晰、有的模糊,不同场景下对识别的要求也不同,但应用没法灵活调整设置来适配这些情况,经常出现某类音频文件没法转写的问题。同时,用户想知道这个应用能识别哪些语言,相关查询功能要么用不了,要么查到的结果没法在界面上正常显示。功能模块和应用界面配合脱节:语音识别的功能模块和应用的操作界面衔接不顺畅。比如用户关掉应用界面后,语音识别的相关功能还在后台偷偷运行,既浪费设备电量,还可能占用设备运行内存拖慢速度;而且界面上的提示消息,比如 “开始录音”“识别成功” 等,经常和实际操作不同步,用户要等很久才能看到反馈,体验很差。1.2原因分析结合鸿蒙系统的使用特点和应用实际开发情况,上述问题的根源主要有以下几点:(一)音频文件操作考虑不周全:读取音频文件时,没提前想到可能出现的意外情况(比如文件损坏、读取到一半突然中断),遇到这些问题时没有对应的处理办法,很容易导致数据断档。而且每次读取的音频数据量是固定的,当文件末尾剩下的数据不够这个量时,就会直接停在那里,造成转写内容不完整。(二)语音识别核心功能没定好 “规矩”:控制语音转文字的核心功能(识别引擎),从开启到关闭的整个过程没有固定的操作顺序,比如还没准备好就强行启动,或者启动后没正常关闭就重复开启,很容易乱套。同时,这个核心功能的实际工作状态(比如是否在运行)和界面上显示的状态(比如 “正在录音”)没关联起来,经常出现 “功能已经停了,界面还在显示运行中” 的滞后情况。(三)状态显示和消息传递没理清楚:界面上的 “正在录音”“正在处理” 等状态,和语音识别的实际进度没绑在一起,识别已经结束了,界面可能还没更新。另外,传递识别结果、报错信息时,既没统一格式,也没标清楚是 “录音识别” 还是 “文件识别” 的消息,导致不同操作的信息混在一起,用户分不清。(四)适配能力和辅助功能有漏洞:没有提前设置一套通用的音频参数(比如常见的录音清晰度、声道数),遇到特殊格式的音频(比如清晰度不同、多声道)就 “不认识”,没法处理。而且查询系统支持哪些语言的识别时,查到的结果没好好整理,没法正常显示在界面上,用户自然看不到可用的识别语言。(五)功能模块和界面 “节奏对不上”:语音识别功能的开启、关闭,和应用界面的打开、关闭没同步。比如用户关掉界面后,语音识别功能可能还在后台偷偷运行,白白浪费设备电量和内存。另外,界面上的提示消息(比如 “开始录音”“识别完成”)没跟上操作步骤,用户操作完要等很久才能看到反馈,体验很不好。1.3解决思路针对前面问题的根源,结合鸿蒙系统的使用特性,制定出一套简单好落地的解决方向,确保每个问题都能精准对应解决,具体如下:(一)完善音频文件操作流程:专门做一个管理音频文件的工具,提前想好文件损坏、读取中断等意外情况的应对办法。同时调整读取方式,就算文件末尾剩下少量数据也能正常读取,确保转写用的音频数据能完整、连续地传递给语音识别功能,读完文件后还能自动释放占用的设备资源。(二)给语音识别核心功能定 “操作规矩”:用一个专属控制器来统筹语音转文字的核心功能,明确规定 “先准备好再启动、结束后必须关闭” 的固定流程。同时把核心功能的实际工作状态和界面显示状态绑在一起,比如功能停止运行了,界面就立刻更新为 “已停止”,避免重复启动、操作冲突的问题,还得确保用完后能彻底清理相关资源。(三)理顺状态显示和消息传递逻辑:给界面上的各类状态(如 “正在录音”)和消息(如识别结果、报错提示)定统一规则。让界面状态紧紧跟着语音识别的实际进度实时更新,而且给录音、文件识别两种操作分别加上专属标识,这样传递消息时就能分清来源,不会出现信息混乱、延迟显示的情况。(四)补全适配漏洞并完善辅助功能:先预设一套通用的音频设置,能适配大多数常见的音频文件;同时留出调整空间,遇到特殊格式的音频时,可手动修改设置适配。另外,优化语言查询功能,把查到的系统支持的识别语言整理成清晰的样式,确保能正常显示在界面上,方便用户查看。(五)让功能模块和界面 “节奏同步”:把语音识别功能的开启、关闭,和应用界面的打开、关闭绑在一起,比如用户关掉界面时,系统自动关闭语音识别功能并清理资源,避免后台偷偷运行浪费资源。同时调整界面提示消息的时机,操作完成后立刻弹出提示,让用户及时知道 “录音已开始”“识别成功” 等结果,提升使用体验。1.4解决方案(一)专属音频文件管理工具:预设文件损坏、读取中断等异常的应对方案,避免数据断档。优化读取逻辑,确保文件末尾少量数据也能完整读取,读完自动释放资源。设专属标识,防止同一文件同时多次读取引发数据混乱。(二)规范语音识别核心功能操作:用专属控制器定流程,需完成参数设置再启动功能,成败均给出明确提示。分开管控录音、文件识别流程,避免操作互相干扰。双重清理资源:设手动关闭按钮,同时界面关闭时自动触发核心功能关停与资源清理。(三)统一状态与消息传递规则:给录音、文件识别的消息加专属标签,避免信息混淆。界面状态与识别实际进度实时绑定,杜绝状态滞后。统一识别结果、报错信息的展示格式,方便用户查看。(四)补全适配漏洞,完善辅助功能:预设通用音频参数,同时预留手动调整入口,适配特殊音频格式。优化语言查询功能,将查询结果整理成清晰列表展示。(五)协调功能模块与界面配合:统一语音识别功能的调用接口,避免界面调用出错。操作与提示同步,如点击录音立即弹出对应提示。对齐功能与界面生命周期,界面关闭时自动关停识别功能,减少资源浪费。文件操作工具类代码示例:import { fileIo } from '@kit.CoreFileKit'; const TAG = 'FileUtil'; const SEND_SIZE: number = 1280; export class FileUtil { private mIsWriting: boolean = false; private mFilePath: string = ''; private mFile: fileIo.File | null = null; private mIsReadFile: boolean = true; private mDataCallBack: ((data: ArrayBuffer) => void) | null = null; public setFilePath(filePath: string) { this.mFilePath = filePath; } async init(dataCallBack: (data: ArrayBuffer) => void): Promise<void> { if (null != this.mDataCallBack) { return; } this.mDataCallBack = dataCallBack; if (!fileIo.accessSync(this.mFilePath)) { return } console.error(TAG, "init start "); } async start(): Promise<void> { try { if (this.mIsWriting || null == this.mDataCallBack) { return; } this.mIsWriting = true; this.mIsReadFile = true; this.mFile = fileIo.openSync(this.mFilePath, fileIo.OpenMode.READ_ONLY); let buf: ArrayBuffer = new ArrayBuffer(SEND_SIZE); let offset: number = 0; while (SEND_SIZE == fileIo.readSync(this.mFile.fd, buf, { offset: offset }) && this.mIsReadFile) { this.mDataCallBack(buf); await sleep(40); offset = offset + SEND_SIZE; } } catch (e) { console.error(TAG, "read file error " + e); } finally { if (null != this.mFile) { fileIo.closeSync(this.mFile); } this.mIsWriting = false; } } stop() { if (null == this.mDataCallBack) { return; } try { this.mIsReadFile = false; } catch (e) { console.error(TAG, "read file error " + e); } } release() { if (null == this.mDataCallBack) { return; } try { this.mDataCallBack = null; this.mIsReadFile = false; } catch (e) { console.error(TAG, "read file error " + e); } } } function sleep(ms: number): Promise<void> { return new Promise<void>(resolve => setTimeout(resolve, ms)); } 语言转文字功能组件代码示例:import { speechRecognizer } from '@kit.CoreSpeechKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { FileUtil } from './FileUtil'; const TAG = 'SpeechRecognition'; /** * 语音识别结果回调接口 */ export interface SpeechRecognitionResult { text: string; isFinal: boolean; sessionId: string; } /** * 语音识别错误回调接口 */ export interface SpeechRecognitionError { code: number; message: string; sessionId: string; } /** * 语音识别配置参数 */ export interface SpeechRecognitionConfig { // 语音识别模式:'short' | 'long' recognizerMode?: string; // 语音识别语言 language?: string; // 是否在线识别 online?: number; // 音频采样率 sampleRate?: number; // 音频声道数 soundChannel?: number; // 音频采样位数 sampleBit?: number; // VAD开始时间 vadBegin?: number; // VAD结束时间 vadEnd?: number; // 最大音频时长 maxAudioDuration?: number; } export interface State { isReady: boolean; isRecording: boolean; isProcessing: boolean } export class SpeechRecognitionController { initialize = (config?: SpeechRecognitionConfig): Promise<boolean> => { return new Promise<boolean>((resolve) => { }); } startRecording = (config?: Partial<SpeechRecognitionConfig>) => { } stopRecording = () => { } recognizeFromFile = (filePath: string, config?: Partial<SpeechRecognitionConfig>): Promise<boolean> => { return new Promise<boolean>((resolve) => { }); } querySupportedLanguages = () => { } release = () => { } } /** * 语音识别组件 */ @Component export struct SpeechRecognition { speechRecognitionController: SpeechRecognitionController = new SpeechRecognitionController(); // 识别结果文本 @State recognitionText: string = "语音转文字"; // 组件是否准备就绪 @State isReady: boolean = false; // 是否正在录音 @State isRecording: boolean = false; // 是否正在处理文件 @State isProcessing: boolean = false; // 会话ID private sessionId: string = this.generateSessionId(); // 文件处理会话ID private fileSessionId: string = this.generateSessionId(); // 语音识别引擎 private asrEngine: speechRecognizer.SpeechRecognitionEngine | null = null; // 文件捕获器 private fileCapturer: FileUtil = new FileUtil(); // 识别结果回调 onRecognitionResult?: (result: SpeechRecognitionResult) => void; // 错误回调 onError?: (error: SpeechRecognitionError) => void; // 状态变化回调 onStateChange?: (state: State) => void; /** * 初始化语音识别引擎 */ async initialize(config?: SpeechRecognitionConfig): Promise<boolean> { try { if (config) { const extraParam: Record<string, Object> = { "locate": "CN", "recognizerMode": config.recognizerMode ? config.recognizerMode : "" }; const initParamsInfo: speechRecognizer.CreateEngineParams = { language: config.language!, online: config.online!, extraParams: extraParam }; return new Promise<boolean>((resolve) => { speechRecognizer.createEngine(initParamsInfo, (err: BusinessError, engine: speechRecognizer.SpeechRecognitionEngine) => { if (!err) { this.asrEngine = engine; this.setRecognitionListener(); this.isReady = true; this.updateState(); console.info(TAG, '语音识别引擎初始化成功'); resolve(true); } else { console.error(TAG, `初始化语音识别引擎失败: ${err.message}`); this.handleError(this.sessionId, 1002200001, `初始化失败: ${err.message}`); resolve(false); } }); }); } else { return false; } } catch (error) { console.error(TAG, `初始化异常: ${error.message}`); this.handleError(this.sessionId, 1002200001, `初始化异常: ${error.message}`); return false; } } /** * 开始录音识别 */ startRecording(config?: Partial<SpeechRecognitionConfig>): void { if (!this.asrEngine || !this.isReady) { this.handleError(this.sessionId, 1002200002, '引擎未初始化'); return; } if (this.isRecording) { this.handleError(this.sessionId, 1002200002, '正在录音中'); return; } try { if (!config) { const defaultConfig: SpeechRecognitionConfig = { sampleRate: 16000, soundChannel: 1, sampleBit: 16, vadBegin: 2000, vadEnd: 3000, maxAudioDuration: 20000 }; config = defaultConfig; } const audioParam: speechRecognizer.AudioInfo = { audioType: 'pcm', sampleRate: config.sampleRate!, soundChannel: config.soundChannel!, sampleBit: config.sampleBit! }; const extraParam: Record<string, Object> = { "recognitionMode": 0, "vadBegin": config.vadBegin!, "vadEnd": config.vadEnd!, "maxAudioDuration": config.maxAudioDuration! }; const recognizerParams: speechRecognizer.StartParams = { sessionId: this.sessionId, audioInfo: audioParam, extraParams: extraParam }; this.asrEngine.startListening(recognizerParams); this.isRecording = true; this.updateState(); console.info(TAG, '开始录音识别'); } catch (error) { console.error(TAG, `开始录音失败: ${error.message}`); this.handleError(this.sessionId, 1002200002, `开始录音失败: ${error.message}`); } } /** * 停止录音识别 */ stopRecording(): void { if (!this.asrEngine || !this.isRecording) { return; } try { this.asrEngine.cancel(this.sessionId); this.isRecording = false; this.updateState(); console.info(TAG, '停止录音识别'); } catch (error) { console.error(TAG, `停止录音失败: ${error.message}`); this.handleError(this.sessionId, 1002200003, `停止录音失败: ${error.message}`); } } /** * 从音频文件识别 */ async recognizeFromFile(filePath: string, config?: Partial<SpeechRecognitionConfig>): Promise<boolean> { if (!this.asrEngine || !this.isReady) { this.handleError(this.fileSessionId, 1002200002, '引擎未初始化'); return false; } if (this.isProcessing) { this.handleError(this.fileSessionId, 1002200002, '正在处理文件中'); return false; } try { if (!config) { const defaultConfig: SpeechRecognitionConfig = { sampleRate: 16000, soundChannel: 1, sampleBit: 16 }; config = defaultConfig; } const audioParam: speechRecognizer.AudioInfo = { audioType: 'pcm', sampleRate: config.sampleRate!, soundChannel: config.soundChannel!, sampleBit: config.sampleBit! }; const recognizerParams: speechRecognizer.StartParams = { sessionId: this.fileSessionId, audioInfo: audioParam }; this.asrEngine.startListening(recognizerParams); this.isProcessing = true; this.updateState(); return new Promise<boolean>((resolve) => { this.fileCapturer.setFilePath(filePath); this.fileCapturer.init(async (dataBuffer: ArrayBuffer) => { try { const uint8Array: Uint8Array = new Uint8Array(dataBuffer); this.asrEngine!.writeAudio(this.fileSessionId, uint8Array); } catch (error) { console.error(TAG, `写入音频数据失败: ${error.message}`); this.handleError(this.fileSessionId, 1002200004, `写入音频数据失败: ${error.message}`); resolve(false); } }); this.fileCapturer.start().then(() => { this.isProcessing = false; this.updateState(); this.fileCapturer.release(); resolve(true); }).catch((error: BusinessError) => { console.error(TAG, `文件处理失败: ${error.message}`); this.handleError(this.fileSessionId, 1002200005, `文件处理失败: ${error.message}`); this.isProcessing = false; this.updateState(); resolve(false); }); }); } catch (error) { console.error(TAG, `文件识别失败: ${error.message}`); this.handleError(this.fileSessionId, 1002200005, `文件识别失败: ${error.message}`); this.isProcessing = false; this.updateState(); return false; } } /** * 查询支持的语言 */ querySupportedLanguages(): void { if (!this.asrEngine) { this.handleError(this.sessionId, 1002200002, '引擎未初始化'); return; } try { const languageQuery: speechRecognizer.LanguageQuery = { sessionId: this.sessionId }; this.asrEngine.listLanguages(languageQuery, (err: BusinessError, languages: Array<string>) => { if (!err) { const resultText = `支持的语言: ${JSON.stringify(languages)}`; this.recognitionText = resultText; console.info(TAG, `查询语言成功: ${resultText}`); } else { console.error(TAG, `查询语言失败: ${err.message}`); this.handleError(this.sessionId, 1002200006, `查询语言失败: ${err.message}`); } }); } catch (error) { console.error(TAG, `查询语言异常: ${error.message}`); this.handleError(this.sessionId, 1002200006, `查询语言异常: ${error.message}`); } } /** * 释放引擎资源 */ release(): void { try { if (this.asrEngine) { this.asrEngine.shutdown(); this.asrEngine = null; this.isReady = false; this.isRecording = false; this.isProcessing = false; this.updateState(); console.info(TAG, '语音识别引擎已释放'); } } catch (error) { console.error(TAG, `释放引擎失败: ${error.message}`); this.handleError(this.sessionId, 1002200007, `释放引擎失败: ${error.message}`); } } /** * 获取当前状态 */ getState(): State { return { isReady: this.isReady, isRecording: this.isRecording, isProcessing: this.isProcessing }; } /** * 获取识别文本 */ getRecognitionText(): string { return this.recognitionText; } aboutToAppear(): void { if (this.speechRecognitionController) { this.speechRecognitionController.initialize = (config?: SpeechRecognitionConfig): Promise<boolean> => { return this.initialize(config); } this.speechRecognitionController.startRecording = (config?: Partial<SpeechRecognitionConfig>) => { this.startRecording(config); } this.speechRecognitionController.stopRecording = () => { this.stopRecording() } this.speechRecognitionController.recognizeFromFile = (filePath: string, config?: Partial<SpeechRecognitionConfig>): Promise<boolean> => { return this.recognizeFromFile(filePath, config); } this.speechRecognitionController.querySupportedLanguages = () => { this.querySupportedLanguages(); } this.speechRecognitionController.release = () => { this.release(); } } } /** * 构建UI */ build() { // 这个组件主要提供功能,UI可以根据需要自定义 // 这里提供一个基础的文本显示 Text(this.recognitionText) .fontColor($r('sys.color.ohos_id_color_text_secondary')) .constraintSize({ minHeight: 100 }) .border({ width: 1, radius: 5 }) .backgroundColor('#d3d3d3') .padding(20) .width('100%') } /** * 设置识别监听器 */ private setRecognitionListener(): void { if (!this.asrEngine) { return; } const listener: speechRecognizer.RecognitionListener = { onStart: (sessionId: string, eventMessage: string) => { this.recognitionText = ''; console.info(TAG, `识别开始, sessionId: ${sessionId}, message: ${eventMessage}`); }, onEvent: (sessionId: string, eventCode: number, eventMessage: string) => { console.info(TAG, `识别事件, sessionId: ${sessionId}, code: ${eventCode}, message: ${eventMessage}`); }, onResult: (sessionId: string, result: speechRecognizer.SpeechRecognitionResult) => { console.info(TAG, `识别结果, sessionId: ${sessionId}, result: ${JSON.stringify(result)}`); this.recognitionText = result.result; if (this.onRecognitionResult) { this.onRecognitionResult({ text: result.result, isFinal: true, // 可以根据实际结果调整 sessionId: sessionId }); } }, onComplete: (sessionId: string, eventMessage: string) => { console.info(TAG, `识别完成, sessionId: ${sessionId}, message: ${eventMessage}`); if (sessionId === this.sessionId) { this.isRecording = false; } else if (sessionId === this.fileSessionId) { this.isProcessing = false; } this.updateState(); }, onError: (sessionId: string, errorCode: number, errorMessage: string) => { console.error(TAG, `识别错误, sessionId: ${sessionId}, code: ${errorCode}, message: ${errorMessage}`); this.handleError(sessionId, errorCode, errorMessage); if (sessionId === this.sessionId) { this.isRecording = false; } else if (sessionId === this.fileSessionId) { this.isProcessing = false; } this.updateState(); } }; this.asrEngine.setListener(listener); } /** * 处理错误 */ private handleError(sessionId: string, errorCode: number, errorMessage: string): void { if (this.onError) { this.onError({ code: errorCode, message: errorMessage, sessionId: sessionId }); } } /** * 更新状态 */ private updateState(): void { if (this.onStateChange) { let state: State = { isReady: this.isReady, isRecording: this.isRecording, isProcessing: this.isProcessing } this.onStateChange(state); } } /** * 生成会话ID */ private generateSessionId(): string { return Date.now().toString() + Math.random().toString(36).substr(2, 9); } } 语言转文字演示页面代码示例:import { SpeechRecognition, SpeechRecognitionController, SpeechRecognitionResult, SpeechRecognitionError, State } from './SpeechRecognition'; import { PromptAction } from '@kit.ArkUI'; @Entry @Component struct Index { @State recognitionText: string = "语音转文字"; @State isReady: boolean = false; @State isRecording: boolean = false; @State isProcessing: boolean = false; private uiContext: UIContext = this.getUIContext(); private promptAction: PromptAction = this.uiContext.getPromptAction(); speechRecognitionController: SpeechRecognitionController = new SpeechRecognitionController(); build() { Column() { Scroll() { Column() { // 语音识别组件 SpeechRecognition({ speechRecognitionController: this.speechRecognitionController, onRecognitionResult: (result: SpeechRecognitionResult) => { this.recognitionText = result.text; console.info('识别结果:', result); }, onError: (error: SpeechRecognitionError) => { this.recognitionText = `错误: ${error.message} (代码: ${error.code})`; this.promptAction.showToast({ message: `错误: ${error.message}`, duration: 3000 }); console.error('识别错误:', error); }, onStateChange: (state: State) => { this.isReady = state.isReady; this.isRecording = state.isRecording; this.isProcessing = state.isProcessing; console.info('状态变化:', state); } }) Row() { Column() { Text(this.recognitionText) .fontColor($r('sys.color.ohos_id_color_text_secondary')) } .width('100%') .constraintSize({ minHeight: 100 }) .border({ width: 1, radius: 5 }) .backgroundColor('#d3d3d3') .padding(20) .alignItems(HorizontalAlign.Start) } .width('100%') .padding({ left: 20, right: 20, top: 20, bottom: 20 }) // 状态显示 Row() { Text(`状态: ${this.isReady ? '就绪' : '未就绪'} | ${this.isRecording ? '录音中' : '未录音'} | ${this.isProcessing ? '处理中' : '空闲'}`) .fontSize(14) .fontColor(Color.Gray) } .width('100%') .padding(10) Button() { Text("初始化引擎") .fontColor(Color.White) .fontSize(20) } .type(ButtonType.Capsule) .backgroundColor("#0x317AE7") .width("80%") .height(50) .margin(10) .onClick(async () => { const success = await this.speechRecognitionController.initialize({ language: 'zh-CN', recognizerMode: 'short', sampleRate: 16000, online: 1 }); if (success) { this.promptAction.showToast({ message: '初始化成功!', duration: 2000 }); } else { this.promptAction.showToast({ message: '初始化失败!', duration: 2000 }); } }) Button() { Text(this.isRecording ? "停止录音" : "开始录音") .fontColor(Color.White) .fontSize(20) } .type(ButtonType.Capsule) .backgroundColor(this.isRecording ? "#FF0000" : "#0x317AE7") .width("80%") .height(50) .margin(10) .onClick(() => { if (this.isRecording) { this.speechRecognitionController.stopRecording(); this.promptAction.showToast({ message: '停止录音', duration: 2000 }); } else { this.speechRecognitionController.startRecording(); this.promptAction.showToast({ message: '开始录音', duration: 2000 }); } }) Button() { Text("文件识别") .fontColor(Color.White) .fontSize(20) } .type(ButtonType.Capsule) .backgroundColor("#0x317AE7") .width("80%") .height(50) .margin(10) .onClick(async () => { // 这里需要提供音频文件路径 const filePath = "你的音频文件路径"; const success = await this.speechRecognitionController.recognizeFromFile(filePath); if (success) { this.promptAction.showToast({ message: '文件识别开始', duration: 2000 }); } else { this.promptAction.showToast({ message: '文件识别失败', duration: 2000 }); } }) Button() { Text("查询支持语言") .fontColor(Color.White) .fontSize(20) } .type(ButtonType.Capsule) .backgroundColor("#0x317AE7") .width("80%") .height(50) .margin(10) .onClick(() => { this.speechRecognitionController.querySupportedLanguages(); this.promptAction.showToast({ message: '查询语言', duration: 2000 }); }) Button() { Text("释放引擎") .fontColor(Color.White) .fontSize(20) } .type(ButtonType.Capsule) .backgroundColor("#0x317AA7") .width("80%") .height(50) .margin(10) .onClick(() => { this.speechRecognitionController.release(); this.promptAction.showToast({ message: '释放引擎', duration: 2000 }); }) } .layoutWeight(1) } .width('100%') .height('100%') } } aboutToDisappear() { // 释放资源 this.speechRecognitionController.release(); } } 1.5方案成果总结鸿蒙语音转文字应用在功能、稳定性、体验等多方面均取得显著改善,具体成果如下:核心功能全面可用:成功打通录音实时转文字、音频文件转写两条核心路径,语音识别结果精准呈现,报错信息明确易懂;语言查询功能也能正常输出清晰的支持语言列表,完全覆盖用户日常语音转文字的核心需求。运行状态稳定可靠:音频文件读取断档、数据混乱的问题彻底解决,语音识别过程中几乎无卡顿、中断情况。即便出现少量异常,也能通过规范的消息标识快速定位原因,大幅降低了应用故障对使用的影响。多场景适配能力达标:预设的通用音频参数可适配市面上多数常见音频格式,手动调整参数的设计又能应对特殊音频的转写需求,无论是日常录音还是导入的音频文件,都能顺利完成转文字操作。用户使用体验显著提升:界面上的录音、处理等状态与实际操作实时同步,再也没有状态滞后的情况;识别结果和提示消息格式统一、来源清晰,用户能快速 get 关键信息,操作反馈及时,整体使用流程顺畅无阻碍。系统适配契合度高:应用严格贴合鸿蒙系统的运行规则,功能与界面的生命周期同步,避免了后台无效运行造成的电量和内存浪费。同时,标准化的接口设计也让应用能更好地融入鸿蒙生态,为后续功能扩展打下良好基础。
  • [技术干货] 开发者技术支持-半模态弹窗技术方案总结
    1、关键技术难点总结1.1 问题说明(一)宿主组件隐藏而绑定的半模态页面跟着消失了:在鸿蒙应用开发中,存在宿主组件隐藏时半模态弹窗意外消失的问题。当用户在打开半模态选择弹窗后,若在操作过程中使绑定半模态弹窗的组件隐藏了,发现弹窗已无故关闭,导致需要重新选择规格,这种过度严格的关联性源于弹窗生命周期与宿主组件过度绑定,未考虑临时性切换场景。(二)数据传递与状态同步:在半模态弹窗编辑了数据时,弹窗编辑的数据需实时同步到主页面上。如笔记功能在主页面通过添加按钮弹出添加笔记半模态窗口,输入了笔记内容,点击确认后,需要及时在主页的笔记列表显示。此外,在电商场景中,用户在半模态弹窗中选择商品规格、数量等信息后,这些数据需要准确无误地传递回主页面并更新相应的展示内容。如果数据传递过程中出现延迟、丢失或不一致,将直接影响用户体验和业务逻辑的正确性。1.2 原因分析生命周期耦合:半模态弹窗与宿主组件的生命周期存在绑定关系,通过隐藏与显示控制宿主组件的存在与否。当宿主组件不存在时,则与宿主组件绑定的半模态窗也会随着不存在。状态管理机制不完善:在复杂的数据交互场景中,弹窗与主页面之间的状态同步缺乏有效的管理机制。当弹窗中数据发生变化时,未能建立可靠的数据传递通道,导致数据无法及时更新到主页面。同时,双向数据绑定的实现方式不当,容易造成数据循环更新或状态不一致的问题。在多层级组件嵌套的情况下,状态传递路径过长,增加了数据同步的复杂性和出错概率。2、解决思路深入理解API:全面掌握bindSheet API的各个参数和功能,特别是detents、dragBar、maskColor等关键配置项模块化设计:将弹窗内容封装在@Builder装饰器中,提高代码复用性和维护性Visibility控制宿主组件显隐:通过控制Visibility.Visible与Visibility.None模式的切换,控制宿主组件的显隐,当宿主组件隐藏时,仍然存在于页面中,半模态页面也会显示。状态管理优化:使用@State和$$语法正确管理弹窗显示状态,确保数据流清晰3、解决方案3.1 定义状态变量// 1. 定义状态变量 @State isSheetOpen: boolean = false @State isDisplay: boolean = true @State sheetMessage: string = "" @State inputValue: string = "" @State selectedOption: string = "选项1" 3.2 创建弹窗内容构建器@Builder CustomSheetContent() { Column() { // 输入框 TextInput({ placeholder: '请输入内容...', text: this.inputValue }) .onChange((value: string) => { this.inputValue = value }) .width('90%') .height(40) .borderRadius(8) .margin({ bottom: 20 }) // 选项列表 Column() { ForEach(['选项1', '选项2', '选项3'], (option: string) => { Row() { Text(option) .fontSize(16) Blank() if (this.selectedOption === option) { Image($r('app.media.ic_arrow_left')) .width(20) .height(20) } } .width('100%') .padding({ left: 15, right: 15, top: 20, bottom: 20 }) .borderRadius(8) .backgroundColor(this.selectedOption === option ? '#f0f0f0' : '#ffffff') .onClick(() => { this.selectedOption = option }) }) } .width('90%') .borderRadius(8) .backgroundColor('#f8f8f8') .margin({ bottom: 20 }) // 操作按钮 Row() { Button('取消') .onClick(() => { this.isSheetOpen = false }) .layoutWeight(1) .margin({ right: 10 }) Button('确认') .onClick(() => { this.sheetMessage = `您输入了: ${this.inputValue}, 选择了: ${this.selectedOption}` this.isSheetOpen = false }) .layoutWeight(1) .margin({ left: 10 }) .backgroundColor(Color.Blue) .fontColor(Color.White) } .width('90%') .margin({ bottom: 20 }) } .width('100%') .height('100%') } 3.3 绑定半模态弹窗,控制宿主组件显隐// 绑定半模态弹窗 Button('打开半模态弹窗') .bindSheet($$this.isSheetOpen, this.CustomSheetContent(), { detents: [SheetSize.MEDIUM, SheetSize.FIT_CONTENT , SheetSize.LARGE], //dragBar: false, // 显示拖拽条 maskColor: Color.Black, // 设置遮罩颜色 backgroundColor: Color.Transparent, // 背景色透明 enableOutsideInteractive: true, // 允许与外部交互 onWillAppear: () => { console.log('弹窗即将出现') }, onWillDisappear: () => { console.log('弹窗即将消失') }, onDetentsDidChange: (height: number) => { console.log(`弹窗高度变化: ${height}`) } }) .visibility(this.isDisplay ? Visibility.Visible : Visibility.None) //控制显隐 4、方案成果总结(一)功能完备性弹窗生命周期独立控制:通过Visibility控制宿主组件显隐而非销毁重建,确保半模态弹窗在宿主组件隐藏时仍能保持显示状态,解决了弹窗意外关闭的问题;多档位高度调节:支持FIT_CONTENT、MEDIUM、LARGE三种预设高度及自定义像素高度,用户可通过拖拽在不同档位间自由切换,满足不同内容展示需求;数据双向同步:建立可靠的双向数据传递通道,确保弹窗内数据变化能实时同步到主页面,主页面状态更新也能及时反馈到弹窗中。(二)交互与体验优化原生交互复用:基于鸿蒙原生bindSheet API实现,操作流畅度与系统组件一致,提供熟悉的操作体验;实时反馈清晰:通过onWillAppear、onWillDisappear等生命周期回调捕获弹窗状态变化,用户可实时掌握弹窗显示/隐藏状态;手势操作自然:支持拖拽条手势操作调整高度,点击遮罩层关闭弹窗等符合用户习惯的交互方式,提升操作便捷性。(三)代码可维护性模块化封装:将弹窗内容封装在@Builder装饰器中,提高代码复用性和维护性,组件内部处理复杂的状态管理逻辑,对外仅暴露简洁的接口;扩展性强:通过SheetOptions配置对象灵活控制弹窗样式与行为,新增功能场景仅需调整相关参数,无需重构核心代码;组件复用性高:
总条数:462 到第
上滑加载中