HBuilderX

HBuilderX

极客开发工具
uni-app

uni-app

开发一次,多端覆盖
uniCloud

uniCloud

云开发平台
HTML5+

HTML5+

增强HTML5的功能体验
MUI

MUI

上万Star的前端框架

【鸿蒙征文】集成鸿蒙三大核心能力,打造高效考研复习工具“学记宝”

鸿蒙征文

作为一个学生开发者,我深知考研复习过程中“知识点零散难整合、复习计划难落地、错题整理效率低”的痛点——身边同学常因打开复习工具卡顿、多设备错题不同步、分享考点需反复跳转而影响复习节奏。结合课程设计任务,我开发了“学记宝”考研复习小工具,集错题本、复习计划提醒于一体,轻量且适配鸿蒙全场景。开发中,为解决工具启动慢、数据同步难、考点分享繁琐等问题,我集成了鸿蒙云开发、云测试、云调试三项开放能力,大幅提升使用体验,以下分享具体集成与落地过程。
一、鸿蒙开放能力体现及集成背景
本次开发核心集成了鸿蒙开放平台的云开发、云测试、云调试三项能力,分别从数据支撑、质量保障、问题排查三个维度解决学生开发过程中的核心难题,各项能力的核心功能及集成背景如下:

  1. 云开发能力:核心功能提供一站式后端云服务,包含云数据库、云存储、云函数等模块,无需开发者搭建独立服务器,即可实现数据的安全存储、多设备实时同步与高效调用。集成背景:作为学生开发者,我既缺乏搭建运维服务器的资金,也没有足够的后端开发经验,而“学记宝”需要支持多用户错题集、复习计划的实时同步,云开发的无服务器架构恰好解决这一痛点,让我能专注于前端功能设计,同时保障数据安全稳定。
  2. 云测试能力:核心功能提供大规模真机设备池与自动化测试工具,支持多版本鸿蒙系统、多品牌机型的兼容性测试,可自动执行功能测试、性能测试并生成详细报告。集成背景:考研工具用户覆盖不同品牌手机,我仅拥有1台测试手机,无法验证多设备适配效果,手动测试效率极低且易遗漏问题,云测试能让我零成本实现全场景兼容性验证,保障工具在各类设备上稳定运行。
  3. 云调试能力:核心功能支持远程连接云端真机,实时操控设备进行调试,可同步查看应用运行日志、断点调试代码,无需本地搭建复杂调试环境。集成背景:开发中常遇到“本地运行正常、他人使用报错”的问题,且部分同学反馈的平板端适配问题无法在我的手机上复现,云调试能让我直接远程调试目标设备,快速定位并解决跨设备问题。
    二、核心能力集成的关键步骤
    三项能力均基于鸿蒙DevEco Studio与开发者平台协同实现,重点聚焦学生开发中“低成本、高效率”的核心需求,具体集成步骤如下:
  4. 云开发能力集成步骤
    核心是通过SDK快速实现数据云端化,解决多设备同步与后端开发难题,关键节点包括:
    • 步骤一:在鸿蒙开发者平台开通云开发服务,创建“学记宝”专属服务空间,获取空间ID、API密钥等配置信息,享受学生开发者免费资源额度。
    • 步骤二:在DevEco Studio中导入云开发SDK,在应用启动类初始化云服务,关联服务空间,代码示例如下:
    public class MyApplication extends AbilityPackage {  
        @Override  
        public void onInitialize() {  
            super.onInitialize();  
            // 云开发初始化,关联学记宝服务空间  
            CloudDB.init(this, new CloudDBConfig.Builder()  
                    .setSpaceId("xuejibao-2025")  
                    .setApiKey("OHOS-CLOUD-KEY-XXX")  
                    .build());  
        }  
    }

    • 步骤三:设计云数据库结构,创建“examPoint”(高频考点)、“errorQuestion”(错题集)、“reviewPlan”(复习计划)三个核心集合,设置字段权限(如用户仅可读写自身数据),通过云函数实现“复习计划到期推送”自动化逻辑。
    1.1.1 2. 云测试能力集成步骤
    核心是利用云端设备池实现多场景兼容性测试,无需购置实体设备,关键节点包括:
    • 步骤一:在DevEco Studio中关联鸿蒙云测试平台,将“学记宝”编译生成的HAP包上传至测试平台,选择适配考研群体常用的设备型号(如华为Mate 60、荣耀Magic6、平板MatePad Pro等),覆盖HarmonyOS 4.0及以上版本。
    • 步骤二:设计自动化测试用例,聚焦核心功能——如考点搜索响应速度、错题添加与同步、复习计划提醒触发,利用平台可视化工具设置测试步骤,例如“打开工具→搜索‘考研数学极限公式’→验证结果加载时间≤500ms”。
    • 步骤三:执行批量测试与性能监控,开启“稳定性测试”模式(持续运行2小时),重点监测CPU占用率、内存泄漏情况,测试完成后获取包含设备适配报告、性能瓶颈分析的详细文档,针对“平板端考点排版错乱”“低版本系统提醒延迟”等问题进行优化。

  5. 云调试能力集成步骤
    核心是通过远程调试解决跨设备问题,实时定位用户反馈的异常,关键节点包括:
    • 步骤一:在云调试平台选择用户反馈异常的设备型号(如某同学反映的荣耀Play8 Pro),发起远程调试请求,获取设备控制权,建立DevEco Studio与云端设备的实时连接。
    • 步骤二:开启日志实时输出与断点调试,让用户复现异常操作(如“添加错题后切换设备未同步”),通过调试工具定位到云数据库同步逻辑的漏洞——未设置“数据变更监听”,实时修改代码并在云端设备验证修复效果。
    • 步骤三:记录问题修复过程与验证结果,便于后续同类问题排查。
    1.2 三、场景落地与实际效果
    三项能力深度落地于“学记宝”的开发全流程与用户使用场景,解决了学生开发的资源限制与工具的核心痛点,量化效果如下:
  6. 云开发能力:开发成本降90%,数据同步零障碍
    集成前,测算自主搭建服务器每月成本约800元;集成后,依托学生免费资源,月均成本仅80元(超出免费额度部分),数据同步延迟≤1秒,多设备使用用户占比从初期15%提升至72%,错题同步成功率100%。
  7. 云测试能力:设备覆盖提升10倍,问题发现率达95%
    落地场景:保障工具在不同设备、系统版本上的兼容性。集成前,仅能测试2台个人设备,上线后收到“平板端排版错乱”“旧手机启动闪退”等反馈,问题修复周期平均3天;集成后,可测试20 台云端设备,提前发现8类兼容性问题,上线后初期故障反馈率从28%降至4%,问题修复周期缩短至4小时。
  8. 云调试能力:问题定位效率提升80%,用户满意度提高
    集成前,同学反馈“错题添加后不显示”,因无法复现问题,排查耗时平均2小时;集成后,通过云调试远程操控同学同款设备,10分钟内定位到“云数据库写入权限配置错误”,实时修复并验证,用户问题解决满意度从65%提升至98%。
    四、总结
    云开发、云测试、云调试三项能力构建了“开发-测试-调试”全流程支撑体系,完美解决了学生开发者“缺资金、缺设备、缺经验”的困境。云开发实现数据低成本同步,云测试保障多设备兼容性,云调试快速响应问题。
    作为学生开发者,鸿蒙开放能力让我深刻感受到技术普惠的力量——无需专业团队支撑,仅凭个人就能开发出高质量工具。
继续阅读 »

作为一个学生开发者,我深知考研复习过程中“知识点零散难整合、复习计划难落地、错题整理效率低”的痛点——身边同学常因打开复习工具卡顿、多设备错题不同步、分享考点需反复跳转而影响复习节奏。结合课程设计任务,我开发了“学记宝”考研复习小工具,集错题本、复习计划提醒于一体,轻量且适配鸿蒙全场景。开发中,为解决工具启动慢、数据同步难、考点分享繁琐等问题,我集成了鸿蒙云开发、云测试、云调试三项开放能力,大幅提升使用体验,以下分享具体集成与落地过程。
一、鸿蒙开放能力体现及集成背景
本次开发核心集成了鸿蒙开放平台的云开发、云测试、云调试三项能力,分别从数据支撑、质量保障、问题排查三个维度解决学生开发过程中的核心难题,各项能力的核心功能及集成背景如下:

  1. 云开发能力:核心功能提供一站式后端云服务,包含云数据库、云存储、云函数等模块,无需开发者搭建独立服务器,即可实现数据的安全存储、多设备实时同步与高效调用。集成背景:作为学生开发者,我既缺乏搭建运维服务器的资金,也没有足够的后端开发经验,而“学记宝”需要支持多用户错题集、复习计划的实时同步,云开发的无服务器架构恰好解决这一痛点,让我能专注于前端功能设计,同时保障数据安全稳定。
  2. 云测试能力:核心功能提供大规模真机设备池与自动化测试工具,支持多版本鸿蒙系统、多品牌机型的兼容性测试,可自动执行功能测试、性能测试并生成详细报告。集成背景:考研工具用户覆盖不同品牌手机,我仅拥有1台测试手机,无法验证多设备适配效果,手动测试效率极低且易遗漏问题,云测试能让我零成本实现全场景兼容性验证,保障工具在各类设备上稳定运行。
  3. 云调试能力:核心功能支持远程连接云端真机,实时操控设备进行调试,可同步查看应用运行日志、断点调试代码,无需本地搭建复杂调试环境。集成背景:开发中常遇到“本地运行正常、他人使用报错”的问题,且部分同学反馈的平板端适配问题无法在我的手机上复现,云调试能让我直接远程调试目标设备,快速定位并解决跨设备问题。
    二、核心能力集成的关键步骤
    三项能力均基于鸿蒙DevEco Studio与开发者平台协同实现,重点聚焦学生开发中“低成本、高效率”的核心需求,具体集成步骤如下:
  4. 云开发能力集成步骤
    核心是通过SDK快速实现数据云端化,解决多设备同步与后端开发难题,关键节点包括:
    • 步骤一:在鸿蒙开发者平台开通云开发服务,创建“学记宝”专属服务空间,获取空间ID、API密钥等配置信息,享受学生开发者免费资源额度。
    • 步骤二:在DevEco Studio中导入云开发SDK,在应用启动类初始化云服务,关联服务空间,代码示例如下:
    public class MyApplication extends AbilityPackage {  
        @Override  
        public void onInitialize() {  
            super.onInitialize();  
            // 云开发初始化,关联学记宝服务空间  
            CloudDB.init(this, new CloudDBConfig.Builder()  
                    .setSpaceId("xuejibao-2025")  
                    .setApiKey("OHOS-CLOUD-KEY-XXX")  
                    .build());  
        }  
    }

    • 步骤三:设计云数据库结构,创建“examPoint”(高频考点)、“errorQuestion”(错题集)、“reviewPlan”(复习计划)三个核心集合,设置字段权限(如用户仅可读写自身数据),通过云函数实现“复习计划到期推送”自动化逻辑。
    1.1.1 2. 云测试能力集成步骤
    核心是利用云端设备池实现多场景兼容性测试,无需购置实体设备,关键节点包括:
    • 步骤一:在DevEco Studio中关联鸿蒙云测试平台,将“学记宝”编译生成的HAP包上传至测试平台,选择适配考研群体常用的设备型号(如华为Mate 60、荣耀Magic6、平板MatePad Pro等),覆盖HarmonyOS 4.0及以上版本。
    • 步骤二:设计自动化测试用例,聚焦核心功能——如考点搜索响应速度、错题添加与同步、复习计划提醒触发,利用平台可视化工具设置测试步骤,例如“打开工具→搜索‘考研数学极限公式’→验证结果加载时间≤500ms”。
    • 步骤三:执行批量测试与性能监控,开启“稳定性测试”模式(持续运行2小时),重点监测CPU占用率、内存泄漏情况,测试完成后获取包含设备适配报告、性能瓶颈分析的详细文档,针对“平板端考点排版错乱”“低版本系统提醒延迟”等问题进行优化。

  5. 云调试能力集成步骤
    核心是通过远程调试解决跨设备问题,实时定位用户反馈的异常,关键节点包括:
    • 步骤一:在云调试平台选择用户反馈异常的设备型号(如某同学反映的荣耀Play8 Pro),发起远程调试请求,获取设备控制权,建立DevEco Studio与云端设备的实时连接。
    • 步骤二:开启日志实时输出与断点调试,让用户复现异常操作(如“添加错题后切换设备未同步”),通过调试工具定位到云数据库同步逻辑的漏洞——未设置“数据变更监听”,实时修改代码并在云端设备验证修复效果。
    • 步骤三:记录问题修复过程与验证结果,便于后续同类问题排查。
    1.2 三、场景落地与实际效果
    三项能力深度落地于“学记宝”的开发全流程与用户使用场景,解决了学生开发的资源限制与工具的核心痛点,量化效果如下:
  6. 云开发能力:开发成本降90%,数据同步零障碍
    集成前,测算自主搭建服务器每月成本约800元;集成后,依托学生免费资源,月均成本仅80元(超出免费额度部分),数据同步延迟≤1秒,多设备使用用户占比从初期15%提升至72%,错题同步成功率100%。
  7. 云测试能力:设备覆盖提升10倍,问题发现率达95%
    落地场景:保障工具在不同设备、系统版本上的兼容性。集成前,仅能测试2台个人设备,上线后收到“平板端排版错乱”“旧手机启动闪退”等反馈,问题修复周期平均3天;集成后,可测试20 台云端设备,提前发现8类兼容性问题,上线后初期故障反馈率从28%降至4%,问题修复周期缩短至4小时。
  8. 云调试能力:问题定位效率提升80%,用户满意度提高
    集成前,同学反馈“错题添加后不显示”,因无法复现问题,排查耗时平均2小时;集成后,通过云调试远程操控同学同款设备,10分钟内定位到“云数据库写入权限配置错误”,实时修复并验证,用户问题解决满意度从65%提升至98%。
    四、总结
    云开发、云测试、云调试三项能力构建了“开发-测试-调试”全流程支撑体系,完美解决了学生开发者“缺资金、缺设备、缺经验”的困境。云开发实现数据低成本同步,云测试保障多设备兼容性,云调试快速响应问题。
    作为学生开发者,鸿蒙开放能力让我深刻感受到技术普惠的力量——无需专业团队支撑,仅凭个人就能开发出高质量工具。
收起阅读 »

【鸿蒙征文】uniapp 实现鸿蒙自定义扫码界面

鸿蒙征文

有些应用场景,需要使用自定义扫码的界面,添加自定义的布局和功能,相比 uniapp 自带的 uni.scanCode ,布局更加自由,完全使用鸿蒙开发的布局能力。

官网 自定义界面扫码 文档提供了明确的接入方案,这里使用 Uniapp 提供的嵌入原生组件来完成,思路都是相通的,回一个就会所有的原生组件方案了。

涉及到鸿蒙原生的组件、原生能力就得使用 uniapp 嵌入原生组件了,详细看文档 嵌入鸿蒙原生组件

整体思路比较简单

  • 测试鸿蒙原生工程写法,完成布局逻辑、扫码逻辑编写,可直接使用官网提供的 demo
  • 编写 uts 代码,引入 ets 文件并完成代码封装
  • 页面中使用

了解原生写法

一图胜千言:

  • 核心逻辑:权限处理-拉齐页面-释放扫码资源
  • 核心方法 init/start/stop/release等

官网中提供了一个示例,

代码相对清晰:

  • 定义基础 state
  • 定义权限请求
  • 定义 customScan.init 完成初始化,并处理扫码逻辑
  • 布局时候添加了闪光灯的按钮

需要注意的是,在核心 ets 代码之外有一个固定的封装

@Component  
struct HarmoyScanLayoutComponent{}  

@Builder  
function ScanLayoutBuilder(options: ScanLayoutBuilderOptions) {  
  HarmoyScanLayoutComponent({  
    enableMultiMode: options.enableMultiMode ?? true,  
    enableAlbum: options.enableAlbum ?? true,  
    onScanResult: options?.on?.get?.('scanresult'),  
    onFlashLightChange: options?.on?.get?.('flashlightchange')  
  })  
    .width(options.width)  
    .height(options.height)  
}  

defineNativeEmbed('harmoy-scan-layout', {  
  builder: ScanLayoutBuilder  
})

参考 uniapp 文档了解即可。这里定义 Component 和 Builder 是固定的写法。

编写 uts 插件代码

HBuilderX 中新建 uni api 插件,定位到 app-harmony 文件夹。新建 index.utsscan.ets

index.uts 比较简单,就一行代码

import './scan.ets'

scan.ets 完整代码简附录。

这里用到了摄像头的权限,因此需要使用 module.json5

{  
  "module": {  
    "requestPermissions": [  
      {  
        "name": "ohos.permission.CAMERA",  
        "usedScene": {  
          "when": "inuse"  
        },  
        "reason": "$string:EntryAbility_desc"  
      }  
    ]  
  }  
}

这里忽略了 interface.uts 和 unierror.uts 的代码。

完整的目录结构是

--app-harmony  
----index.uts  
----scan.ets  
--interface.uts  
--unierror.uts

页面中使用

<template>  
    <view>  
        <embed class="scan-area" tag="harmoy-scan-layout" :options="options" @scanresult="onScanResult"  
            @flashlightchange="onFlashChange" />  
    </view>  
</template>  

<script>  
    import '@/uni_modules/harmoy-scan-layout'  
    export default {  
        data() {  
            return {  
                options: {  
                    // 可选:是否开启多码识别(默认 true)  
                    enableMultiMode: true,  
                    // 可选:是否允许相册识别(默认 true)  
                    enableAlbum: true  
                }  
            }  
        },  
        methods: {  
            onScanResult(e) {  
                uni.showToast({  
                    title:JSON.stringify(e)  
                })  
                console.log('scanresult', e.detail.results)  
            },  
            onFlashChange(e) {  
                console.log('flashlight enabled:', e.detail.enabled)  
            }  
        }  
    }  
</script>  

<style scoped>  
    .scan-area {  
        display: block;  
        width: 100%;  
        height: 90vh;  
    }  
</style>

附录 scan.ets 代码

import abilityAccessCtrl from '@ohos.abilityAccessCtrl'  
import common from '@ohos.app.ability.common'  
import { customScan, scanBarcode, scanCore } from '@kit.ScanKit';  
import { BusinessError } from '@kit.BasicServicesKit';  

const TAG: string = '[harmoy-scan-layout]'  

interface ScanLayoutBuilderOptions extends NativeEmbedBuilderOptions {  
  // 是否开启多码识别(默认: true)  
  enableMultiMode?: boolean  
  // 是否允许从相册识别(默认: true)  
  enableAlbum?: boolean  
}  

interface ScanResultEventDetail {  
  results: Array<scanBarcode.ScanResult>  
}  

@Component  
struct HarmoyScanLayoutComponent {  
  // 组件参数  
  @Prop enableMultiMode: boolean  
  @Prop enableAlbum: boolean  
  // 事件回调(通过 options.on 传入)  
  onScanResult?: Function  
  onFlashLightChange?: Function  
  // 状态  
  @State userGrant: boolean = false  
  @State surfaceId: string = ''  
  @State isShowBack: boolean = false  
  @State isFlashLightEnable: boolean = false  
  @State isSensorLight: boolean = false  
  @State cameraHeight: number = 640  
  @State cameraWidth: number = 360  
  @State offsetX: number = 0  
  @State offsetY: number = 0  
  @State zoomValue: number = 1  
  @State setZoomValue: number = 1  
  @State scaleValue: number = 1  
  @State pinchValue: number = 1  
  @State displayHeight: number = 0  
  @State displayWidth: number = 0  
  @State scanResult: Array<scanBarcode.ScanResult> = []  
  canIUse: boolean = canIUse("SystemCapability.Multimedia.Scan.ScanBarcode")  
  private mXComponentController: XComponentController = new XComponentController()  

  aboutToAppear(): void {  
    // 生命周期开始:申请权限并初始化  
    (async () => {  
      await this.requestCameraPermission()  
      this.setDisplay()  
      try {  
        const options: scanBarcode.ScanOptions = {  
          // 与平台枚举对齐,由内部适配完成  
          scanTypes: [scanCore.ScanType.ALL],  

          enableMultiMode: this.enableMultiMode ?? true,  
          enableAlbum: this.enableAlbum ?? true  
        }  
        if (this.canIUse) {  
          customScan.init(options)  
        } else {  
          // Fallback for unsupported SystemCapability  
        }  
      } catch (error) {  
        hilog.error(0x0001, TAG, `Failed to init customScan. Code: ${error?.code}, message: ${error?.message}`)  
      }  
    })()  
  }  

  aboutToDisappear(): void {  
    // 生命周期结束:停止与释放  
    this.userGrant = false  
    this.isFlashLightEnable = false  
    this.isSensorLight = false  
    try {  
      customScan.off?.('lightingFlash')  
    } catch (error) {  
      hilog.error(0x0001, TAG, `Failed to off lightingFlash. Code: ${error?.code}, message: ${error?.message}`)  
    }  
    this.customScanStop()  
    try {  
      customScan.release?.().catch((error: BusinessError) => {  
        hilog.error(0x0001, TAG, `Failed to release customScan. Code: ${error?.code}, message: ${error?.message}`)  
      })  
    } catch (error) {  
      hilog.error(0x0001, TAG, `Failed to release customScan. Code: ${error?.code}, message: ${error?.message}`)  
    }  
  }  

  // 用户申请权限  
  async reqPermissionsFromUser(): Promise<number[]> {  
    hilog.info(0x0001, TAG, 'reqPermissionsFromUser start')  
    const context = (this.getUIContext().getHostContext() as common.UIAbilityContext)  
    const atManager = abilityAccessCtrl.createAtManager()  
    try {  
      const grantStatus = await atManager.requestPermissionsFromUser(context, ['ohos.permission.CAMERA'])  
      return grantStatus.authResults  
    } catch (error) {  
      hilog.error(0x0001, TAG, `Failed to requestPermissionsFromUser. Code: ${error?.code}, message: ${error?.message}`)  
      return []  
    }  
  }  

  // 用户申请相机权限  
  async requestCameraPermission() {  
    const grantStatus = await this.reqPermissionsFromUser()  
    for (let i = 0; i < grantStatus.length; i++) {  
      if (grantStatus[i] === 0) {  
        hilog.info(0x0001, TAG, 'Succeeded in getting permissions.')  
        this.userGrant = true  
        break  
      }  
    }  
  }  

  // 竖屏时获取屏幕尺寸,设置预览流全屏示例  
  setDisplay() {  
    try {  
      const displayClass = display.getDefaultDisplaySync()  
      this.displayHeight = this.getUIContext().px2vp(displayClass.height)  
      this.displayWidth = this.getUIContext().px2vp(displayClass.width)  
      const maxLen: number = Math.max(this.displayWidth, this.displayHeight)  
      const minLen: number = Math.min(this.displayWidth, this.displayHeight)  
      const RATIO: number = 16 / 9  
      this.cameraHeight = maxLen  
      this.cameraWidth = maxLen / RATIO  
      this.offsetX = (minLen - this.cameraWidth) / 2  
    } catch (error) {  
      hilog.error(0x0001, TAG, `Failed to getDefaultDisplaySync. Code: ${error?.code}, message: ${error?.message}`)  
    }  
  }  

  // toast显示扫码结果  
  async showScanResult(result: string = 'ok') {  
    try {  
      this.getUIContext().getPromptAction().showToast({  
        message: result,  
        duration: 3000  
      })  
    } catch (error) {  
      hilog.error(0x0001, TAG, `Failed to showToast. Code: ${error?.code}, message: ${error?.message}`)  
    }  
  }  

  initCamera() {  
    this.isShowBack = false  
    this.scanResult = []  
    const viewControl: customScan.ViewControl = {  
      width: this.cameraWidth,  
      height: this.cameraHeight,  
      surfaceId: this.surfaceId  
    }  
    try {  
        if (canIUse("SystemCapability.Multimedia.Scan.ScanBarcode")) {  
          customScan.start(viewControl)  
            .then((result) => {  
              hilog.info(0x0001, TAG, `result: ${JSON.stringify(result)}`)  
              if (result?.length) {  
                this.scanResult = result  
                this.isShowBack = true  
                // 事件回传  
                if (this.onScanResult) {  
                  console.log('onScanResult', result)  
                  const detail: ScanResultEventDetail = { results: result }  
                  this.onScanResult({ detail })  
                }  
                this.customScanStop()  
              }  
            })  
            .catch((error: BusinessError) => {  
              hilog.error(0x0001, TAG, `Failed to start customScan. Code: ${error?.code}, message: ${error?.message}`)  
            })  
        } else {  
          // Fallback for unsupported SystemCapability  
        }  
    } catch (error) {  
      hilog.error(0x0001, TAG, `Failed to start customScan. Code: ${error?.code}, message: ${error?.message}`)  
    }  
  }  

  customScanStop() {  
    try {  
      if (this.canIUse) {  
        customScan.stop().catch((error: BusinessError) => {  
          hilog.error(0x0001, TAG, `Failed to stop customScan. Code: ${error?.code}, message: ${error?.message}`)  
        })  
      } else {  
        // Fallback for unsupported SystemCapability  
      }  
    } catch (error) {  
      hilog.error(0x0001, TAG, `Failed to stop customScan. Code: ${error?.code}, message: ${error?.message}`)  
    }  
  }  

  public customGetZoom(): number {  
    let zoom = 1  
    try {  
      zoom = customScan.getZoom?.() ?? 1  
      hilog.info(0x0001, TAG, `Succeeded in getting zoom, zoom: ${zoom}`)  
    } catch (error) {  
      hilog.error(0x0001, TAG, `Failed to get zoom. Code: ${error?.code}, message: ${error?.message}`)  
    }  
    return zoom  
  }  

  public customSetZoom(pinchValue: number): void {  
    try {  
      customScan.setZoom?.(pinchValue)  
      hilog.info(0x0001, TAG, `Succeeded in setting zoom.`)  
    } catch (error) {  
      hilog.error(0x0001, TAG, `Failed to set zoom. Code: ${error?.code}, message: ${error?.message}`)  
    }  
  }  

  build() {  
    Stack() {  
      if (this.userGrant) {  
        Column() {  
          XComponent({  
            id: 'componentId',  
            type: XComponentType.SURFACE,  
            controller: this.mXComponentController  
          })  
            .onLoad(async () => {  
              hilog.info(0x0001, TAG, 'Succeeded in loading, onLoad is called.')  
              this.surfaceId = this.mXComponentController.getXComponentSurfaceId()  
              hilog.info(0x0001, TAG, `Succeeded in getting surfaceId: ${this.surfaceId}`)  
              this.initCamera()  
              // 闪光灯监听  
              try {  
                customScan.on?.('lightingFlash', (error: BusinessError, isLightingFlash: boolean) => {  
                  if (error) {  
                    hilog.error(0x0001, TAG,  
                      `Failed to on lightingFlash. Code: ${error?.code}, message: ${error?.message}`)  
                    return  
                  }  
                  if (isLightingFlash) {  
                    this.isFlashLightEnable = true  
                  } else {  
                    try {  
                      const status = customScan.getFlashLightStatus?.()  
                      if (!status) {  
                        this.isFlashLightEnable = false  
                      }  
                    } catch (error) {  
                      hilog.error(0x0001, TAG,  
                        `Failed to get flashLightStatus. Code: ${error?.code}, message: ${error?.message}`)  
                    }  
                  }  
                  this.isSensorLight = isLightingFlash  
                  if (this.onFlashLightChange) {  
                    // this.onFlashLightChange({ detail: { enabled: this.isFlashLightEnable } })  
                    console.warn('onFlashLightChange==')  

                  }  
                })  
              } catch (error) {  
                hilog.error(0x0001, TAG,  
                  `Failed to bind lightingFlash. Code: ${error?.code}, message: ${error?.message}`)  
              }  
            })  
            .width(this.cameraWidth)  
            .height(this.cameraHeight)  
            .position({ x: this.offsetX, y: this.offsetY })  
        }  
        .height('100%')  
        .width('100%')  
      }  

      // 操作区(简单控制:闪光灯、重新扫码、变焦)  
      Column() {  
        Row() {  
          Button('FlashLight')  
            .onClick(() => {  
              let lightStatus: boolean = false  
              try {  
                lightStatus = customScan.getFlashLightStatus?.() ?? false  
              } catch (error) {  
                hilog.error(0x0001, TAG,  
                  `Failed to get flashLightStatus. Code: ${error?.code}, message: ${error?.message}`)  
              }  
              if (lightStatus) {  
                try {  
                  customScan.closeFlashLight?.()  
                  setTimeout(() => {  
                    this.isFlashLightEnable = this.isSensorLight  
                    if (this.onFlashLightChange) {  
                      // this.onFlashLightChange({ detail: { enabled: this.isFlashLightEnable } })  
                      console.log('onFlashLightChange==')  
                    }  
                  }, 200)  
                } catch (error) {  
                  hilog.error(0x0001, TAG,  
                    `Failed to close flashLight. Code: ${error?.code}, message: ${error?.message}`)  
                }  
              } else {  
                try {  
                  customScan.openFlashLight();  
                  this.isFlashLightEnable = true  
                  if (this.onFlashLightChange) {  
                    // this.onFlashLightChange({ detail: { enabled: this.isFlashLightEnable } })  
                    console.log('onFlashLightChange==')  
                  }  
                } catch (error) {  
                  hilog.error(0x0001, TAG,  
                    `Failed to open flashLight. Code: ${error?.code}, message: ${error?.message}`)  
                }  
              }  
            })  
            .visibility((this.userGrant && this.isFlashLightEnable) ? Visibility.Visible : Visibility.None)  

          Button('Scan')  
            .onClick(() => {  
              this.initCamera()  
            })  
            .visibility(this.isShowBack ? Visibility.Visible : Visibility.None)  
        }  
        .margin({ top: 10, bottom: 10 })  

        Row() {  
          Button('缩放比例,当前比例:' + this.setZoomValue)  
            .onClick(() => {  
              if (!this.isShowBack) {  
                if (!this.zoomValue || this.zoomValue === this.setZoomValue) {  
                  this.setZoomValue = this.customGetZoom()  
                } else {  
                  this.zoomValue = this.zoomValue  
                  this.customSetZoom(this.zoomValue)  
                  setTimeout(() => {  
                    if (!this.isShowBack) {  
                      this.setZoomValue = this.customGetZoom()  
                    }  
                  }, 600)  
                }  
              }  
            })  
        }  
      }  
      .width('50%')  
      .height(180)  
    }  
    .width('100%')  
    .height('100%')  
    .onClick((event: ClickEvent) => {  
      if (this.isShowBack) {  
        return  
      }  
      // 设置点击对焦  
      const x1 = this.getUIContext().vp2px(event.displayY) / (this.displayHeight + 0.0)  
      const y1 = 1.0 - (this.getUIContext().vp2px(event.displayX) / (this.displayWidth + 0.0))  
      try {  
        customScan.setFocusPoint?.({ x: x1, y: y1 })  
        hilog.info(0x0001, TAG, `Succeeded to set focusPoint x1: ${x1}, y1: ${y1}`)  
      } catch (error) {  
        hilog.error(0x0001, TAG, `Failed to set focusPoint. Code: ${error?.code}, message: ${error?.message}`)  
      }  
      setTimeout(() => {  
        try {  
          customScan.resetFocus?.()  
        } catch (error) {  
          hilog.error(0x0001, TAG, `Failed to reset focus. Code: ${error?.code}, message: ${error?.message}`)  
        }  
      }, 200)  
    })  
    .gesture(PinchGesture({ fingers: 2 })  
      .onActionUpdate((event: GestureEvent) => {  
        if (event) {  
          this.scaleValue = event.scale  
        }  
      })  
      .onActionEnd((_: GestureEvent) => {  
        if (this.isShowBack) {  
          return  
        }  
        try {  
          const zoom = this.customGetZoom()  
          this.pinchValue = this.scaleValue * zoom  
          this.customSetZoom(this.pinchValue)  
          hilog.info(0x0001, TAG, 'Pinch end')  
        } catch (error) {  
          hilog.error(0x0001, TAG, `Failed to set zoom. Code: ${error?.code}, message: ${error?.message}`)  
        }  
      })  
    )  
  }  
}  
继续阅读 »

有些应用场景,需要使用自定义扫码的界面,添加自定义的布局和功能,相比 uniapp 自带的 uni.scanCode ,布局更加自由,完全使用鸿蒙开发的布局能力。

官网 自定义界面扫码 文档提供了明确的接入方案,这里使用 Uniapp 提供的嵌入原生组件来完成,思路都是相通的,回一个就会所有的原生组件方案了。

涉及到鸿蒙原生的组件、原生能力就得使用 uniapp 嵌入原生组件了,详细看文档 嵌入鸿蒙原生组件

整体思路比较简单

  • 测试鸿蒙原生工程写法,完成布局逻辑、扫码逻辑编写,可直接使用官网提供的 demo
  • 编写 uts 代码,引入 ets 文件并完成代码封装
  • 页面中使用

了解原生写法

一图胜千言:

  • 核心逻辑:权限处理-拉齐页面-释放扫码资源
  • 核心方法 init/start/stop/release等

官网中提供了一个示例,

代码相对清晰:

  • 定义基础 state
  • 定义权限请求
  • 定义 customScan.init 完成初始化,并处理扫码逻辑
  • 布局时候添加了闪光灯的按钮

需要注意的是,在核心 ets 代码之外有一个固定的封装

@Component  
struct HarmoyScanLayoutComponent{}  

@Builder  
function ScanLayoutBuilder(options: ScanLayoutBuilderOptions) {  
  HarmoyScanLayoutComponent({  
    enableMultiMode: options.enableMultiMode ?? true,  
    enableAlbum: options.enableAlbum ?? true,  
    onScanResult: options?.on?.get?.('scanresult'),  
    onFlashLightChange: options?.on?.get?.('flashlightchange')  
  })  
    .width(options.width)  
    .height(options.height)  
}  

defineNativeEmbed('harmoy-scan-layout', {  
  builder: ScanLayoutBuilder  
})

参考 uniapp 文档了解即可。这里定义 Component 和 Builder 是固定的写法。

编写 uts 插件代码

HBuilderX 中新建 uni api 插件,定位到 app-harmony 文件夹。新建 index.utsscan.ets

index.uts 比较简单,就一行代码

import './scan.ets'

scan.ets 完整代码简附录。

这里用到了摄像头的权限,因此需要使用 module.json5

{  
  "module": {  
    "requestPermissions": [  
      {  
        "name": "ohos.permission.CAMERA",  
        "usedScene": {  
          "when": "inuse"  
        },  
        "reason": "$string:EntryAbility_desc"  
      }  
    ]  
  }  
}

这里忽略了 interface.uts 和 unierror.uts 的代码。

完整的目录结构是

--app-harmony  
----index.uts  
----scan.ets  
--interface.uts  
--unierror.uts

页面中使用

<template>  
    <view>  
        <embed class="scan-area" tag="harmoy-scan-layout" :options="options" @scanresult="onScanResult"  
            @flashlightchange="onFlashChange" />  
    </view>  
</template>  

<script>  
    import '@/uni_modules/harmoy-scan-layout'  
    export default {  
        data() {  
            return {  
                options: {  
                    // 可选:是否开启多码识别(默认 true)  
                    enableMultiMode: true,  
                    // 可选:是否允许相册识别(默认 true)  
                    enableAlbum: true  
                }  
            }  
        },  
        methods: {  
            onScanResult(e) {  
                uni.showToast({  
                    title:JSON.stringify(e)  
                })  
                console.log('scanresult', e.detail.results)  
            },  
            onFlashChange(e) {  
                console.log('flashlight enabled:', e.detail.enabled)  
            }  
        }  
    }  
</script>  

<style scoped>  
    .scan-area {  
        display: block;  
        width: 100%;  
        height: 90vh;  
    }  
</style>

附录 scan.ets 代码

import abilityAccessCtrl from '@ohos.abilityAccessCtrl'  
import common from '@ohos.app.ability.common'  
import { customScan, scanBarcode, scanCore } from '@kit.ScanKit';  
import { BusinessError } from '@kit.BasicServicesKit';  

const TAG: string = '[harmoy-scan-layout]'  

interface ScanLayoutBuilderOptions extends NativeEmbedBuilderOptions {  
  // 是否开启多码识别(默认: true)  
  enableMultiMode?: boolean  
  // 是否允许从相册识别(默认: true)  
  enableAlbum?: boolean  
}  

interface ScanResultEventDetail {  
  results: Array<scanBarcode.ScanResult>  
}  

@Component  
struct HarmoyScanLayoutComponent {  
  // 组件参数  
  @Prop enableMultiMode: boolean  
  @Prop enableAlbum: boolean  
  // 事件回调(通过 options.on 传入)  
  onScanResult?: Function  
  onFlashLightChange?: Function  
  // 状态  
  @State userGrant: boolean = false  
  @State surfaceId: string = ''  
  @State isShowBack: boolean = false  
  @State isFlashLightEnable: boolean = false  
  @State isSensorLight: boolean = false  
  @State cameraHeight: number = 640  
  @State cameraWidth: number = 360  
  @State offsetX: number = 0  
  @State offsetY: number = 0  
  @State zoomValue: number = 1  
  @State setZoomValue: number = 1  
  @State scaleValue: number = 1  
  @State pinchValue: number = 1  
  @State displayHeight: number = 0  
  @State displayWidth: number = 0  
  @State scanResult: Array<scanBarcode.ScanResult> = []  
  canIUse: boolean = canIUse("SystemCapability.Multimedia.Scan.ScanBarcode")  
  private mXComponentController: XComponentController = new XComponentController()  

  aboutToAppear(): void {  
    // 生命周期开始:申请权限并初始化  
    (async () => {  
      await this.requestCameraPermission()  
      this.setDisplay()  
      try {  
        const options: scanBarcode.ScanOptions = {  
          // 与平台枚举对齐,由内部适配完成  
          scanTypes: [scanCore.ScanType.ALL],  

          enableMultiMode: this.enableMultiMode ?? true,  
          enableAlbum: this.enableAlbum ?? true  
        }  
        if (this.canIUse) {  
          customScan.init(options)  
        } else {  
          // Fallback for unsupported SystemCapability  
        }  
      } catch (error) {  
        hilog.error(0x0001, TAG, `Failed to init customScan. Code: ${error?.code}, message: ${error?.message}`)  
      }  
    })()  
  }  

  aboutToDisappear(): void {  
    // 生命周期结束:停止与释放  
    this.userGrant = false  
    this.isFlashLightEnable = false  
    this.isSensorLight = false  
    try {  
      customScan.off?.('lightingFlash')  
    } catch (error) {  
      hilog.error(0x0001, TAG, `Failed to off lightingFlash. Code: ${error?.code}, message: ${error?.message}`)  
    }  
    this.customScanStop()  
    try {  
      customScan.release?.().catch((error: BusinessError) => {  
        hilog.error(0x0001, TAG, `Failed to release customScan. Code: ${error?.code}, message: ${error?.message}`)  
      })  
    } catch (error) {  
      hilog.error(0x0001, TAG, `Failed to release customScan. Code: ${error?.code}, message: ${error?.message}`)  
    }  
  }  

  // 用户申请权限  
  async reqPermissionsFromUser(): Promise<number[]> {  
    hilog.info(0x0001, TAG, 'reqPermissionsFromUser start')  
    const context = (this.getUIContext().getHostContext() as common.UIAbilityContext)  
    const atManager = abilityAccessCtrl.createAtManager()  
    try {  
      const grantStatus = await atManager.requestPermissionsFromUser(context, ['ohos.permission.CAMERA'])  
      return grantStatus.authResults  
    } catch (error) {  
      hilog.error(0x0001, TAG, `Failed to requestPermissionsFromUser. Code: ${error?.code}, message: ${error?.message}`)  
      return []  
    }  
  }  

  // 用户申请相机权限  
  async requestCameraPermission() {  
    const grantStatus = await this.reqPermissionsFromUser()  
    for (let i = 0; i < grantStatus.length; i++) {  
      if (grantStatus[i] === 0) {  
        hilog.info(0x0001, TAG, 'Succeeded in getting permissions.')  
        this.userGrant = true  
        break  
      }  
    }  
  }  

  // 竖屏时获取屏幕尺寸,设置预览流全屏示例  
  setDisplay() {  
    try {  
      const displayClass = display.getDefaultDisplaySync()  
      this.displayHeight = this.getUIContext().px2vp(displayClass.height)  
      this.displayWidth = this.getUIContext().px2vp(displayClass.width)  
      const maxLen: number = Math.max(this.displayWidth, this.displayHeight)  
      const minLen: number = Math.min(this.displayWidth, this.displayHeight)  
      const RATIO: number = 16 / 9  
      this.cameraHeight = maxLen  
      this.cameraWidth = maxLen / RATIO  
      this.offsetX = (minLen - this.cameraWidth) / 2  
    } catch (error) {  
      hilog.error(0x0001, TAG, `Failed to getDefaultDisplaySync. Code: ${error?.code}, message: ${error?.message}`)  
    }  
  }  

  // toast显示扫码结果  
  async showScanResult(result: string = 'ok') {  
    try {  
      this.getUIContext().getPromptAction().showToast({  
        message: result,  
        duration: 3000  
      })  
    } catch (error) {  
      hilog.error(0x0001, TAG, `Failed to showToast. Code: ${error?.code}, message: ${error?.message}`)  
    }  
  }  

  initCamera() {  
    this.isShowBack = false  
    this.scanResult = []  
    const viewControl: customScan.ViewControl = {  
      width: this.cameraWidth,  
      height: this.cameraHeight,  
      surfaceId: this.surfaceId  
    }  
    try {  
        if (canIUse("SystemCapability.Multimedia.Scan.ScanBarcode")) {  
          customScan.start(viewControl)  
            .then((result) => {  
              hilog.info(0x0001, TAG, `result: ${JSON.stringify(result)}`)  
              if (result?.length) {  
                this.scanResult = result  
                this.isShowBack = true  
                // 事件回传  
                if (this.onScanResult) {  
                  console.log('onScanResult', result)  
                  const detail: ScanResultEventDetail = { results: result }  
                  this.onScanResult({ detail })  
                }  
                this.customScanStop()  
              }  
            })  
            .catch((error: BusinessError) => {  
              hilog.error(0x0001, TAG, `Failed to start customScan. Code: ${error?.code}, message: ${error?.message}`)  
            })  
        } else {  
          // Fallback for unsupported SystemCapability  
        }  
    } catch (error) {  
      hilog.error(0x0001, TAG, `Failed to start customScan. Code: ${error?.code}, message: ${error?.message}`)  
    }  
  }  

  customScanStop() {  
    try {  
      if (this.canIUse) {  
        customScan.stop().catch((error: BusinessError) => {  
          hilog.error(0x0001, TAG, `Failed to stop customScan. Code: ${error?.code}, message: ${error?.message}`)  
        })  
      } else {  
        // Fallback for unsupported SystemCapability  
      }  
    } catch (error) {  
      hilog.error(0x0001, TAG, `Failed to stop customScan. Code: ${error?.code}, message: ${error?.message}`)  
    }  
  }  

  public customGetZoom(): number {  
    let zoom = 1  
    try {  
      zoom = customScan.getZoom?.() ?? 1  
      hilog.info(0x0001, TAG, `Succeeded in getting zoom, zoom: ${zoom}`)  
    } catch (error) {  
      hilog.error(0x0001, TAG, `Failed to get zoom. Code: ${error?.code}, message: ${error?.message}`)  
    }  
    return zoom  
  }  

  public customSetZoom(pinchValue: number): void {  
    try {  
      customScan.setZoom?.(pinchValue)  
      hilog.info(0x0001, TAG, `Succeeded in setting zoom.`)  
    } catch (error) {  
      hilog.error(0x0001, TAG, `Failed to set zoom. Code: ${error?.code}, message: ${error?.message}`)  
    }  
  }  

  build() {  
    Stack() {  
      if (this.userGrant) {  
        Column() {  
          XComponent({  
            id: 'componentId',  
            type: XComponentType.SURFACE,  
            controller: this.mXComponentController  
          })  
            .onLoad(async () => {  
              hilog.info(0x0001, TAG, 'Succeeded in loading, onLoad is called.')  
              this.surfaceId = this.mXComponentController.getXComponentSurfaceId()  
              hilog.info(0x0001, TAG, `Succeeded in getting surfaceId: ${this.surfaceId}`)  
              this.initCamera()  
              // 闪光灯监听  
              try {  
                customScan.on?.('lightingFlash', (error: BusinessError, isLightingFlash: boolean) => {  
                  if (error) {  
                    hilog.error(0x0001, TAG,  
                      `Failed to on lightingFlash. Code: ${error?.code}, message: ${error?.message}`)  
                    return  
                  }  
                  if (isLightingFlash) {  
                    this.isFlashLightEnable = true  
                  } else {  
                    try {  
                      const status = customScan.getFlashLightStatus?.()  
                      if (!status) {  
                        this.isFlashLightEnable = false  
                      }  
                    } catch (error) {  
                      hilog.error(0x0001, TAG,  
                        `Failed to get flashLightStatus. Code: ${error?.code}, message: ${error?.message}`)  
                    }  
                  }  
                  this.isSensorLight = isLightingFlash  
                  if (this.onFlashLightChange) {  
                    // this.onFlashLightChange({ detail: { enabled: this.isFlashLightEnable } })  
                    console.warn('onFlashLightChange==')  

                  }  
                })  
              } catch (error) {  
                hilog.error(0x0001, TAG,  
                  `Failed to bind lightingFlash. Code: ${error?.code}, message: ${error?.message}`)  
              }  
            })  
            .width(this.cameraWidth)  
            .height(this.cameraHeight)  
            .position({ x: this.offsetX, y: this.offsetY })  
        }  
        .height('100%')  
        .width('100%')  
      }  

      // 操作区(简单控制:闪光灯、重新扫码、变焦)  
      Column() {  
        Row() {  
          Button('FlashLight')  
            .onClick(() => {  
              let lightStatus: boolean = false  
              try {  
                lightStatus = customScan.getFlashLightStatus?.() ?? false  
              } catch (error) {  
                hilog.error(0x0001, TAG,  
                  `Failed to get flashLightStatus. Code: ${error?.code}, message: ${error?.message}`)  
              }  
              if (lightStatus) {  
                try {  
                  customScan.closeFlashLight?.()  
                  setTimeout(() => {  
                    this.isFlashLightEnable = this.isSensorLight  
                    if (this.onFlashLightChange) {  
                      // this.onFlashLightChange({ detail: { enabled: this.isFlashLightEnable } })  
                      console.log('onFlashLightChange==')  
                    }  
                  }, 200)  
                } catch (error) {  
                  hilog.error(0x0001, TAG,  
                    `Failed to close flashLight. Code: ${error?.code}, message: ${error?.message}`)  
                }  
              } else {  
                try {  
                  customScan.openFlashLight();  
                  this.isFlashLightEnable = true  
                  if (this.onFlashLightChange) {  
                    // this.onFlashLightChange({ detail: { enabled: this.isFlashLightEnable } })  
                    console.log('onFlashLightChange==')  
                  }  
                } catch (error) {  
                  hilog.error(0x0001, TAG,  
                    `Failed to open flashLight. Code: ${error?.code}, message: ${error?.message}`)  
                }  
              }  
            })  
            .visibility((this.userGrant && this.isFlashLightEnable) ? Visibility.Visible : Visibility.None)  

          Button('Scan')  
            .onClick(() => {  
              this.initCamera()  
            })  
            .visibility(this.isShowBack ? Visibility.Visible : Visibility.None)  
        }  
        .margin({ top: 10, bottom: 10 })  

        Row() {  
          Button('缩放比例,当前比例:' + this.setZoomValue)  
            .onClick(() => {  
              if (!this.isShowBack) {  
                if (!this.zoomValue || this.zoomValue === this.setZoomValue) {  
                  this.setZoomValue = this.customGetZoom()  
                } else {  
                  this.zoomValue = this.zoomValue  
                  this.customSetZoom(this.zoomValue)  
                  setTimeout(() => {  
                    if (!this.isShowBack) {  
                      this.setZoomValue = this.customGetZoom()  
                    }  
                  }, 600)  
                }  
              }  
            })  
        }  
      }  
      .width('50%')  
      .height(180)  
    }  
    .width('100%')  
    .height('100%')  
    .onClick((event: ClickEvent) => {  
      if (this.isShowBack) {  
        return  
      }  
      // 设置点击对焦  
      const x1 = this.getUIContext().vp2px(event.displayY) / (this.displayHeight + 0.0)  
      const y1 = 1.0 - (this.getUIContext().vp2px(event.displayX) / (this.displayWidth + 0.0))  
      try {  
        customScan.setFocusPoint?.({ x: x1, y: y1 })  
        hilog.info(0x0001, TAG, `Succeeded to set focusPoint x1: ${x1}, y1: ${y1}`)  
      } catch (error) {  
        hilog.error(0x0001, TAG, `Failed to set focusPoint. Code: ${error?.code}, message: ${error?.message}`)  
      }  
      setTimeout(() => {  
        try {  
          customScan.resetFocus?.()  
        } catch (error) {  
          hilog.error(0x0001, TAG, `Failed to reset focus. Code: ${error?.code}, message: ${error?.message}`)  
        }  
      }, 200)  
    })  
    .gesture(PinchGesture({ fingers: 2 })  
      .onActionUpdate((event: GestureEvent) => {  
        if (event) {  
          this.scaleValue = event.scale  
        }  
      })  
      .onActionEnd((_: GestureEvent) => {  
        if (this.isShowBack) {  
          return  
        }  
        try {  
          const zoom = this.customGetZoom()  
          this.pinchValue = this.scaleValue * zoom  
          this.customSetZoom(this.pinchValue)  
          hilog.info(0x0001, TAG, 'Pinch end')  
        } catch (error) {  
          hilog.error(0x0001, TAG, `Failed to set zoom. Code: ${error?.code}, message: ${error?.message}`)  
        }  
      })  
    )  
  }  
}  
收起阅读 »

【鸿蒙征文】分享我 uniapp 集成鸿蒙企业微信的经验

鸿蒙征文

目前跑通了应用唤起企业微信登录和分享的方案,封装了 uts 插件,特地参加鸿蒙征文活动,分享经验给大家。

插件开发背景

目前官方没有提供企业微信 sdk 的封装,工作中又需要,其实走下来不难,一通百通。大致流程如下:

  1. 下载离线 sdk 和 补充隐私协议
  2. 企业微信后台录入鸿蒙包名和 appid
  3. 编写 uts 代码和调用

了解 uts 插件开发之后几乎就是照搬了。

下载离线 sdk

企业微信文档上提供了一个下载链接,可以访问 企业内部开发-客户端API-移动端SDK-企业微信登录-Harmony应用 下载下来, lib_wwapi.har,这个先放好。

网页上还有隐私协议的文字部分,这个交给运营人员填写一下

SDK名称:企业微信登录/分享SDK
主要功能:通过企业微信登录/分享SDK,企业可在自建的app引入sdk,从而可使用企业微信账号登录app,并且在app中分享消息到企业微信。
使用说明:详见本接入指南
开发者:深圳市腾讯计算机系统有限公司
隐私政策:请接入企业微信登录/分享 SDK的开发者,认真阅读《企业微信登录/分享SDK个人信息保护规则》,并确保对企业微信登录/分享SDK的接入使用情况符合上述规则的相关要求。

微信后台录入应用信息

登录 企业微信的后台 ,提前创建好企业内部应用。然后找到应用管理,点击接入 企业微信授权登录

点开之后找到鸿蒙的信息部分,填写鸿蒙的 APPID 和包名,填写完成之后一个 scheme 存好。

以防你不知道,鸿蒙应用的包名,在华为后台这里获取,appid 是一串数字。

顺便把下面的参数提前准备好

  • 企业 id。在 企业微信的后台 找到我的企业,最下方字符串。 ww开头
  • 应用 id。在企业微信后台-应用管理-应用详情,找到 AgentId 是一串数字。数字 10 开头
  • scheme id。刚才企业微信后台填写鸿蒙应用时候展示的 scheme 字符串。wwauth 开头

编写 uts 插件

在 HBuilderX 工程中,创建 uts 插件,选择 uts api 插件开发,名称比如叫 hamrony-comwechat,定位到 app-harmony 文件夹。

工程配置与依赖。

引入企业微信 HAR 包

在插件目录 uni_modules/harmony-com-wechat/utssdk/app-harmony/config.json 声明依赖,确保 DevEco 构建时可打包。把第一步里的 sdk 放到 libs 文件夹内。

{  
  "dependencies": {  
    "@tencent/wecom_open_sdk": "./libs/lib_wwapi.har"  
  }  
}

配置 scheme

在工程 module.json5 或 manifest 中补充 querySchemes,与后台登记完全一致。["wxworkapi","https"]。否则无法被企业微信识别与拉起。

在 unpackage 里找到 app-harmony 工程产物文件,找到 entry/src/main/module.json5 赋值出来,放到 harmony-configs/entry/src/main/module.json5,添加 querySchemes

UTS 代码翻译

UTS 和 ArkTS 比较相似,Harmony 侧通过企业微信 SDK 完成安装检测与认证发起。核心流程:

  • WWAPIFactory.createWWAPI(context) 获取实例;
  • isWWAppInstalled(WWKApiAppType.AppTypeSaaS) 判断是否安装企业微信;
  • 构造 WWAuthMessage.Req,写入 scheme/corpid/agentId/scopes
  • sendMessage 拉起企业微信,回调中读取 code,交由服务端换取登录态。

编辑 uni_modules/harmony-comwechat/utssdk/app-harmony/index.uts

大部分代码都不用变,就 context 稍微改一下。

import { WWAPIFactory, WWAuthMessage, BaseMessage, WWKApiAppType, IWWAPIEventHandler } from '@tencent/wecom_open_sdk';  

export const canOpenComWechat = () => {  
    const context = UTSHarmony.getUIAbilityContext()  
    // 基本使用 导入  
    let wwapi = WWAPIFactory.createWWAPI(context)  
    return wwapi.isWWAppInstalled(WWKApiAppType.AppTypeSaaS)//是否安装了企业微信  
}  

export const handleLogin = (SCHEME : string, CORPID : string, agentId : string) => {  

    const context = UTSHarmony.getUIAbilityContext()  
    // 基本使用 导入  
    let wwapi = WWAPIFactory.createWWAPI(context)  

    let msg = new WWAuthMessage.Req("", SCHEME)  
    msg.appId = CORPID  
    msg.scopes = ["snsapi_base"]  
    msg.agentId = agentId  

    const cb : IWWAPIEventHandler = {  
        handleResp: (rsp : BaseMessage | undefined | null) : void => {  
            if (rsp !== null) {  
                console.log('res code===', rsp.code)  
                // 这里返回页面里调用后端接口  
            }  
            if (rsp instanceof WWAuthMessage.Resp) {  
                //rsp.code   
                console.info(JSON.stringify(rsp))  
            }  
        }  
    }  

    wwapi.sendMessage(context, msg, WWKApiAppType.AppTypeSaaS, cb)  
}  

说明:

  • UTSHarmony.getUIAbilityContext() 自动获取当前 UIAbility;
  • 回调中仅打印日志,留给业务侧继续把 code 发给后端做换票。
  • interface.utsunierror.uts 保持模板,后续如需统一错误码可在 MyAPIErrors 维护映射。
  • 分享和这个逻辑一样
  • 页面里使用 canOpenComWechat 返回 true 之后调用 handleLogin

页面使用

在页面按需引入并触发登录动作,以下示例来自 pages/index/index.vue(代码不变):

<script setup>  
import {canOpenComWechat,handle} from '@/uni_modules/harmony-com-wechat'  
const handle1=()=>{  
  console.log(canOpenComWechat())  
}  
const share=()=>{  
  handle('wwauthexxx','wwe2xx','1000002')  
}  
</script>

调用方法即可。回调返回的 code 需要发送至后端,与企业微信服务器完成 code2session 等后续流程。

六. 调试要点与排错清单

  • 未拉起企业微信:优先比对 querySchemes 是否与后台登记完全一致;签名与包名也需对应。
  • 已拉起但无回调:确认 agentId/scopes 是否符合该企业应用权限;企业微信端可能限制。
  • 回调拿到 code:务必在后端调用企业微信接口换票,不建议在端内直连。
  • 多环境联调:开发/测试/生产环境的 scheme/corpid/agentid 请分环境管理,避免错配。

参考文档:https://developer.work.weixin.qq.com/document/path/101021。如需扩展分享等更多能力,可沿用同一 SDK 延伸接口,有问题留言我会协助排查。

继续阅读 »

目前跑通了应用唤起企业微信登录和分享的方案,封装了 uts 插件,特地参加鸿蒙征文活动,分享经验给大家。

插件开发背景

目前官方没有提供企业微信 sdk 的封装,工作中又需要,其实走下来不难,一通百通。大致流程如下:

  1. 下载离线 sdk 和 补充隐私协议
  2. 企业微信后台录入鸿蒙包名和 appid
  3. 编写 uts 代码和调用

了解 uts 插件开发之后几乎就是照搬了。

下载离线 sdk

企业微信文档上提供了一个下载链接,可以访问 企业内部开发-客户端API-移动端SDK-企业微信登录-Harmony应用 下载下来, lib_wwapi.har,这个先放好。

网页上还有隐私协议的文字部分,这个交给运营人员填写一下

SDK名称:企业微信登录/分享SDK
主要功能:通过企业微信登录/分享SDK,企业可在自建的app引入sdk,从而可使用企业微信账号登录app,并且在app中分享消息到企业微信。
使用说明:详见本接入指南
开发者:深圳市腾讯计算机系统有限公司
隐私政策:请接入企业微信登录/分享 SDK的开发者,认真阅读《企业微信登录/分享SDK个人信息保护规则》,并确保对企业微信登录/分享SDK的接入使用情况符合上述规则的相关要求。

微信后台录入应用信息

登录 企业微信的后台 ,提前创建好企业内部应用。然后找到应用管理,点击接入 企业微信授权登录

点开之后找到鸿蒙的信息部分,填写鸿蒙的 APPID 和包名,填写完成之后一个 scheme 存好。

以防你不知道,鸿蒙应用的包名,在华为后台这里获取,appid 是一串数字。

顺便把下面的参数提前准备好

  • 企业 id。在 企业微信的后台 找到我的企业,最下方字符串。 ww开头
  • 应用 id。在企业微信后台-应用管理-应用详情,找到 AgentId 是一串数字。数字 10 开头
  • scheme id。刚才企业微信后台填写鸿蒙应用时候展示的 scheme 字符串。wwauth 开头

编写 uts 插件

在 HBuilderX 工程中,创建 uts 插件,选择 uts api 插件开发,名称比如叫 hamrony-comwechat,定位到 app-harmony 文件夹。

工程配置与依赖。

引入企业微信 HAR 包

在插件目录 uni_modules/harmony-com-wechat/utssdk/app-harmony/config.json 声明依赖,确保 DevEco 构建时可打包。把第一步里的 sdk 放到 libs 文件夹内。

{  
  "dependencies": {  
    "@tencent/wecom_open_sdk": "./libs/lib_wwapi.har"  
  }  
}

配置 scheme

在工程 module.json5 或 manifest 中补充 querySchemes,与后台登记完全一致。["wxworkapi","https"]。否则无法被企业微信识别与拉起。

在 unpackage 里找到 app-harmony 工程产物文件,找到 entry/src/main/module.json5 赋值出来,放到 harmony-configs/entry/src/main/module.json5,添加 querySchemes

UTS 代码翻译

UTS 和 ArkTS 比较相似,Harmony 侧通过企业微信 SDK 完成安装检测与认证发起。核心流程:

  • WWAPIFactory.createWWAPI(context) 获取实例;
  • isWWAppInstalled(WWKApiAppType.AppTypeSaaS) 判断是否安装企业微信;
  • 构造 WWAuthMessage.Req,写入 scheme/corpid/agentId/scopes
  • sendMessage 拉起企业微信,回调中读取 code,交由服务端换取登录态。

编辑 uni_modules/harmony-comwechat/utssdk/app-harmony/index.uts

大部分代码都不用变,就 context 稍微改一下。

import { WWAPIFactory, WWAuthMessage, BaseMessage, WWKApiAppType, IWWAPIEventHandler } from '@tencent/wecom_open_sdk';  

export const canOpenComWechat = () => {  
    const context = UTSHarmony.getUIAbilityContext()  
    // 基本使用 导入  
    let wwapi = WWAPIFactory.createWWAPI(context)  
    return wwapi.isWWAppInstalled(WWKApiAppType.AppTypeSaaS)//是否安装了企业微信  
}  

export const handleLogin = (SCHEME : string, CORPID : string, agentId : string) => {  

    const context = UTSHarmony.getUIAbilityContext()  
    // 基本使用 导入  
    let wwapi = WWAPIFactory.createWWAPI(context)  

    let msg = new WWAuthMessage.Req("", SCHEME)  
    msg.appId = CORPID  
    msg.scopes = ["snsapi_base"]  
    msg.agentId = agentId  

    const cb : IWWAPIEventHandler = {  
        handleResp: (rsp : BaseMessage | undefined | null) : void => {  
            if (rsp !== null) {  
                console.log('res code===', rsp.code)  
                // 这里返回页面里调用后端接口  
            }  
            if (rsp instanceof WWAuthMessage.Resp) {  
                //rsp.code   
                console.info(JSON.stringify(rsp))  
            }  
        }  
    }  

    wwapi.sendMessage(context, msg, WWKApiAppType.AppTypeSaaS, cb)  
}  

说明:

  • UTSHarmony.getUIAbilityContext() 自动获取当前 UIAbility;
  • 回调中仅打印日志,留给业务侧继续把 code 发给后端做换票。
  • interface.utsunierror.uts 保持模板,后续如需统一错误码可在 MyAPIErrors 维护映射。
  • 分享和这个逻辑一样
  • 页面里使用 canOpenComWechat 返回 true 之后调用 handleLogin

页面使用

在页面按需引入并触发登录动作,以下示例来自 pages/index/index.vue(代码不变):

<script setup>  
import {canOpenComWechat,handle} from '@/uni_modules/harmony-com-wechat'  
const handle1=()=>{  
  console.log(canOpenComWechat())  
}  
const share=()=>{  
  handle('wwauthexxx','wwe2xx','1000002')  
}  
</script>

调用方法即可。回调返回的 code 需要发送至后端,与企业微信服务器完成 code2session 等后续流程。

六. 调试要点与排错清单

  • 未拉起企业微信:优先比对 querySchemes 是否与后台登记完全一致;签名与包名也需对应。
  • 已拉起但无回调:确认 agentId/scopes 是否符合该企业应用权限;企业微信端可能限制。
  • 回调拿到 code:务必在后端调用企业微信接口换票,不建议在端内直连。
  • 多环境联调:开发/测试/生产环境的 scheme/corpid/agentid 请分环境管理,避免错配。

参考文档:https://developer.work.weixin.qq.com/document/path/101021。如需扩展分享等更多能力,可沿用同一 SDK 延伸接口,有问题留言我会协助排查。

收起阅读 »

l-floating-panel 添加的多标签的主题内容使用transform: translateY(10px);会导致l-floating-panel 主题内容里面的image无法加载显示

最近开发一个浮动弹框的功能,插件选择的是l-floating-panel ,插件主题内容里面有一个可以跟随软键盘弹出收回的输入框,开始使用到了ransform: translateY(10px);加transiton 来实现,在功能实现之后发现组件内的主题内容里面的所有的标签image都不能显示,花费大量的事件排查之后,发现需要删除ransform: translateY(10px);才可以显示,最后尝试改用margin-bottom或bottom来实现,大家必坑,希望官方能排查下解决。

继续阅读 »

最近开发一个浮动弹框的功能,插件选择的是l-floating-panel ,插件主题内容里面有一个可以跟随软键盘弹出收回的输入框,开始使用到了ransform: translateY(10px);加transiton 来实现,在功能实现之后发现组件内的主题内容里面的所有的标签image都不能显示,花费大量的事件排查之后,发现需要删除ransform: translateY(10px);才可以显示,最后尝试改用margin-bottom或bottom来实现,大家必坑,希望官方能排查下解决。

收起阅读 »

【鸿蒙征文】从鸿蒙适配到迎娶白富美:一个程序员的逆袭之路

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

从鸿蒙适配到迎娶白富美:一个程序员的逆袭之路

2025年春天,公司正处在发展的十字路口。我们是一家深耕工业物联网多年的科技企业,手握多项核心专利,却始终卡在“最后一公里”——客户落地难、交付周期长、跨平台兼容性差。就在这个节骨眼上,一通电话打破了平静。

“华为生态链头部客户要下大单!但前提是——必须在两周内完成APP对鸿蒙Next系统的完整适配,并支持元服务功能。”
——董事长在紧急会议上宣布,语气中带着前所未有的凝重。

会议室鸦雀无声。技术总监老张脸色发白:“鸿蒙Next已经彻底抛弃安卓兼容层,这意味着我们现有的Android原生App几乎要推倒重来……两周?不可能!”

我坐在角落,默默听着大家的焦虑。作为公司里唯一长期使用 uni-app 开发多端应用的工程师,我心里却燃起一丝希望。


危机中的转机:uni-app 成为破局关键

那天晚上,我翻出自己过去三年用 uni-app 维护的十几个项目——覆盖微信小程序、iOS、Android,甚至 H5。突然想起不久前看到的一条消息:uni-app v3.99+ 已全面支持鸿蒙 Next 与元服务

“如果能复用现有代码,或许真有机会!”我立刻打开 DCloud 官网文档,确认了三点关键信息:

  1. 语法兼容:依然使用 Vue 3 + TypeScript,无需学习 ArkTS;
  2. API 桥接uni.login()uni.share() 等接口已自动适配鸿蒙环境;
  3. 元服务支持:只需在 pages.json 中标记页面为原子化服务即可。

第二天一早,我直接敲开了董事长办公室的门。

“王董,给我三天时间。如果搞不定,我主动辞职。”

他盯着我看了十秒,最终点头:“好,资源你随便调,失败不追责,成功——你就是技术负责人。”


72 小时极限攻坚:从零到鸿蒙上架

第一天:环境搭建 + 证书配置(4 小时)

我迅速安装 HUAWEI DevEco Studio,导入公司主力产品“智联工控”的 uni-app 工程。得益于 uni-app 的标准化结构,项目几乎无痛迁移。

# 使用HBX命令行工具初始化鸿蒙项目  
hbx create --type app --name SmartFactory --template uni-preset-vue

接着在华为AGC平台申请四大证书(p12、csr、cer、p7b)。过程比想象中简单,尤其对比苹果那套繁琐流程,华为的自动化程度令人惊喜。

第二天:核心代码改造(8小时)

重点改造四个模块:

1. 登录体系切换为华为账号

原代码依赖自建账号系统,现需接入华为OAuth:

// 改造前(自建登录)  
uni.request({  
  url: 'https://api.company.com/login',  
  data: { username, password }  
});  

// 改造后(鸿蒙静默登录)  
uni.login({  
  success: (res) => {  
    // 获取 authCode  
    const code = res.code;  
    // 后台用 code 换取 OpenID 和手机号  
    uni.request({  
      url: 'https://api.company.com/harmony/login',  
      method: 'POST',  
      data: { auth_code: code }  
    });  
  }  
});

后端同事配合极快,当晚就完成了华为OAuth2.0对接。

2. 权限申请适配

鸿蒙对权限管理更严格,需动态申请:

// 请求位置权限  
uni.authorize({  
  scope: 'scope.location',  
  success() {  
    uni.getLocation({ type: 'gcj02' });  
  }  
});

3. 分享功能切换 provider

// 原微信分享  
uni.share({  
  provider: 'weixin',  
  scene: 'WXSceneSession'  
});  

// 鸿蒙分享(自动识别环境)  
uni.share({  
  provider: 'harmony', // uni-app 自动路由到鸿蒙API  
  title: '设备运行状态报告',  
  summary: '点击查看实时数据'  
});

4. 元服务卡片开发

最关键的突破!我们在 pages.json 中新增原子化页面:

{  
  "pages": [  
    {  
      "path": "pages/dashboard/mini",  
      "style": {  
        "isEntry": true,  
        "isAtomic": true,  
        "navigationBarTitleText": "设备监控"  
      }  
    }  
  ]  
}

用户无需安装完整APP,即可通过服务卡片查看设备温度、能耗等核心指标——这正是客户最看重的功能!

第三天:真机调试 + 上架审核(6小时)

用华为nova 12真机测试,启动速度比Android版快40%。提交至华为应用市场后,仅5小时就通过审核!

当我在群里发出“已上架”截图时,整个技术部沸腾了。


董事长的赏识与命运的转折

庆功宴上,董事长举杯对我说:“小陈,从今天起,你就是CTO助理,直接向我汇报。”

更让我意外的是,他女儿林婉清——公司新任业务总监,竟主动加我微信:“下次客户演示,你跟我一起去。”

林婉清,海归MBA,肤白貌美,气质干练。过去我只敢远远看一眼,如今却要并肩作战。

接下来三个月,我们接连拿下三个鸿蒙生态订单:

  1. 智慧园区项目:用uni-app快速输出鸿蒙+元服务双版本,客户现场扫码即用;
  2. 医疗巡检系统:利用uni-app的跨端能力,一套代码同时交付iOS、Android、HarmonyOS;
  3. 零售POS终端:通过鸿蒙分布式能力,实现手机与POS机无缝协同。

每次演示,她负责讲解业务价值,我负责现场编码调试。有一次客户临时要求增加NFC打卡功能,我当场用uni-app插件市场集成鸿蒙NFC模块:

// 调用鸿蒙NFC读取标签  
const nfc = uni.requireNativePlugin('HarmonyNFC');  
nfc.readTag((data) => {  
  console.log('NFC标签内容:', data);  
});

客户当场签约。林婉清看着我,眼里闪着光:“你真是我的幸运星。”


技术成就爱情:uni-app成红娘

渐渐地,我们的合作从工作延伸到生活。她喜欢喝星巴克,我就用uni-app写了个“咖啡优惠券聚合”小程序送她;她出差怕丢行李,我给她做了个带蓝牙追踪的鸿蒙元服务卡片。

去年圣诞节,我在公司年会上公开表白,背景屏播放着我们共同开发的六个鸿蒙应用图标,最后定格在一行代码:

<template>  
  <view class="love">  
    {{ herName }} + {{ myName }} = Forever  
  </view>  
</template>  

<script setup>  
const herName = 'Lin Wanqing';  
const myName = 'Chen Tech';  
</script>

全场欢呼。董事长笑着点头:“我早就看出来了——技术过硬,人品可靠,配得上我女儿。”

今年五一,我们领证了。婚礼上,我送给她的不是钻戒,而是一个定制的uni-app项目——《我们的爱情编年史》,支持鸿蒙、iOS、Android三端同步,连元服务卡片都做了纪念日提醒。


结语:时代红利属于准备好的人

回望这段经历,我深知:不是我有多厉害,而是选对了工具,踩准了风口。

uni-app让我在技术变革浪潮中脱颖而出;鸿蒙Next的生态红利,给了普通人逆袭的机会。如果你也在焦虑“是否要学ArkTS”“旧项目怎么办”,我的答案是:

别重写,用uni-app改造!三天,足够改变命运。

如今,作为公司技术负责人,我带队全面转向uni-app + 鸿蒙架构。而林婉清常说:“当初那个敢赌三天的男人,现在是我最坚实的依靠。”

技术改变世界,也改变了我的人生。

继续阅读 »

从鸿蒙适配到迎娶白富美:一个程序员的逆袭之路

2025年春天,公司正处在发展的十字路口。我们是一家深耕工业物联网多年的科技企业,手握多项核心专利,却始终卡在“最后一公里”——客户落地难、交付周期长、跨平台兼容性差。就在这个节骨眼上,一通电话打破了平静。

“华为生态链头部客户要下大单!但前提是——必须在两周内完成APP对鸿蒙Next系统的完整适配,并支持元服务功能。”
——董事长在紧急会议上宣布,语气中带着前所未有的凝重。

会议室鸦雀无声。技术总监老张脸色发白:“鸿蒙Next已经彻底抛弃安卓兼容层,这意味着我们现有的Android原生App几乎要推倒重来……两周?不可能!”

我坐在角落,默默听着大家的焦虑。作为公司里唯一长期使用 uni-app 开发多端应用的工程师,我心里却燃起一丝希望。


危机中的转机:uni-app 成为破局关键

那天晚上,我翻出自己过去三年用 uni-app 维护的十几个项目——覆盖微信小程序、iOS、Android,甚至 H5。突然想起不久前看到的一条消息:uni-app v3.99+ 已全面支持鸿蒙 Next 与元服务

“如果能复用现有代码,或许真有机会!”我立刻打开 DCloud 官网文档,确认了三点关键信息:

  1. 语法兼容:依然使用 Vue 3 + TypeScript,无需学习 ArkTS;
  2. API 桥接uni.login()uni.share() 等接口已自动适配鸿蒙环境;
  3. 元服务支持:只需在 pages.json 中标记页面为原子化服务即可。

第二天一早,我直接敲开了董事长办公室的门。

“王董,给我三天时间。如果搞不定,我主动辞职。”

他盯着我看了十秒,最终点头:“好,资源你随便调,失败不追责,成功——你就是技术负责人。”


72 小时极限攻坚:从零到鸿蒙上架

第一天:环境搭建 + 证书配置(4 小时)

我迅速安装 HUAWEI DevEco Studio,导入公司主力产品“智联工控”的 uni-app 工程。得益于 uni-app 的标准化结构,项目几乎无痛迁移。

# 使用HBX命令行工具初始化鸿蒙项目  
hbx create --type app --name SmartFactory --template uni-preset-vue

接着在华为AGC平台申请四大证书(p12、csr、cer、p7b)。过程比想象中简单,尤其对比苹果那套繁琐流程,华为的自动化程度令人惊喜。

第二天:核心代码改造(8小时)

重点改造四个模块:

1. 登录体系切换为华为账号

原代码依赖自建账号系统,现需接入华为OAuth:

// 改造前(自建登录)  
uni.request({  
  url: 'https://api.company.com/login',  
  data: { username, password }  
});  

// 改造后(鸿蒙静默登录)  
uni.login({  
  success: (res) => {  
    // 获取 authCode  
    const code = res.code;  
    // 后台用 code 换取 OpenID 和手机号  
    uni.request({  
      url: 'https://api.company.com/harmony/login',  
      method: 'POST',  
      data: { auth_code: code }  
    });  
  }  
});

后端同事配合极快,当晚就完成了华为OAuth2.0对接。

2. 权限申请适配

鸿蒙对权限管理更严格,需动态申请:

// 请求位置权限  
uni.authorize({  
  scope: 'scope.location',  
  success() {  
    uni.getLocation({ type: 'gcj02' });  
  }  
});

3. 分享功能切换 provider

// 原微信分享  
uni.share({  
  provider: 'weixin',  
  scene: 'WXSceneSession'  
});  

// 鸿蒙分享(自动识别环境)  
uni.share({  
  provider: 'harmony', // uni-app 自动路由到鸿蒙API  
  title: '设备运行状态报告',  
  summary: '点击查看实时数据'  
});

4. 元服务卡片开发

最关键的突破!我们在 pages.json 中新增原子化页面:

{  
  "pages": [  
    {  
      "path": "pages/dashboard/mini",  
      "style": {  
        "isEntry": true,  
        "isAtomic": true,  
        "navigationBarTitleText": "设备监控"  
      }  
    }  
  ]  
}

用户无需安装完整APP,即可通过服务卡片查看设备温度、能耗等核心指标——这正是客户最看重的功能!

第三天:真机调试 + 上架审核(6小时)

用华为nova 12真机测试,启动速度比Android版快40%。提交至华为应用市场后,仅5小时就通过审核!

当我在群里发出“已上架”截图时,整个技术部沸腾了。


董事长的赏识与命运的转折

庆功宴上,董事长举杯对我说:“小陈,从今天起,你就是CTO助理,直接向我汇报。”

更让我意外的是,他女儿林婉清——公司新任业务总监,竟主动加我微信:“下次客户演示,你跟我一起去。”

林婉清,海归MBA,肤白貌美,气质干练。过去我只敢远远看一眼,如今却要并肩作战。

接下来三个月,我们接连拿下三个鸿蒙生态订单:

  1. 智慧园区项目:用uni-app快速输出鸿蒙+元服务双版本,客户现场扫码即用;
  2. 医疗巡检系统:利用uni-app的跨端能力,一套代码同时交付iOS、Android、HarmonyOS;
  3. 零售POS终端:通过鸿蒙分布式能力,实现手机与POS机无缝协同。

每次演示,她负责讲解业务价值,我负责现场编码调试。有一次客户临时要求增加NFC打卡功能,我当场用uni-app插件市场集成鸿蒙NFC模块:

// 调用鸿蒙NFC读取标签  
const nfc = uni.requireNativePlugin('HarmonyNFC');  
nfc.readTag((data) => {  
  console.log('NFC标签内容:', data);  
});

客户当场签约。林婉清看着我,眼里闪着光:“你真是我的幸运星。”


技术成就爱情:uni-app成红娘

渐渐地,我们的合作从工作延伸到生活。她喜欢喝星巴克,我就用uni-app写了个“咖啡优惠券聚合”小程序送她;她出差怕丢行李,我给她做了个带蓝牙追踪的鸿蒙元服务卡片。

去年圣诞节,我在公司年会上公开表白,背景屏播放着我们共同开发的六个鸿蒙应用图标,最后定格在一行代码:

<template>  
  <view class="love">  
    {{ herName }} + {{ myName }} = Forever  
  </view>  
</template>  

<script setup>  
const herName = 'Lin Wanqing';  
const myName = 'Chen Tech';  
</script>

全场欢呼。董事长笑着点头:“我早就看出来了——技术过硬,人品可靠,配得上我女儿。”

今年五一,我们领证了。婚礼上,我送给她的不是钻戒,而是一个定制的uni-app项目——《我们的爱情编年史》,支持鸿蒙、iOS、Android三端同步,连元服务卡片都做了纪念日提醒。


结语:时代红利属于准备好的人

回望这段经历,我深知:不是我有多厉害,而是选对了工具,踩准了风口。

uni-app让我在技术变革浪潮中脱颖而出;鸿蒙Next的生态红利,给了普通人逆袭的机会。如果你也在焦虑“是否要学ArkTS”“旧项目怎么办”,我的答案是:

别重写,用uni-app改造!三天,足够改变命运。

如今,作为公司技术负责人,我带队全面转向uni-app + 鸿蒙架构。而林婉清常说:“当初那个敢赌三天的男人,现在是我最坚实的依靠。”

技术改变世界,也改变了我的人生。

收起阅读 »

关于uni-push VIVO厂商本地通知分类管控公告

vivo unipush

关于uni-push,即日起vivo推送基于《 消息分类说明》对本地通知进行分类管控,若应用在2026年1月1日前未完成渠道备案申请以及未设置channel_id,发送的本地通知将默认为运营消息。详情请查阅《 vivo本地通知分类管控公告》。

为了不影响您应用的重要消息推送,建议您:

  1. 在vivo开发者平台完成渠道备案申请,接入流程详见《 本地通知接入说明》。

  2. 若您发送个推在线消息是纯透传类型,并且应用端在接收消息后,调用本地通知接口展示消息到通知栏,则需要对应设置所申请的通知渠道id和渠道名称,个推服务端API无需改动;若您发送个推在线消息是通知类型,则调用个推服务端API时,设置channel_id和channel_name参数,对应填入所申请的通知渠道id和渠道名称,接口详见《公共请求参数-push_message 在线个推通道消息内容 接口》。

(注:

  1. 本地通知指应用在运行时,直接调用系统接口,不通过厂商vpush系统通道发送的通知。

  2. 透传,即只发送数据给应用端,个推并不做任何处理,应用端可以自己解析字符串进行业务逻辑的实现,比如语音播报、或者创建自定义通知等;通知,即为经个推sdk自动处理后,在通知栏以通知形式展示。)

以上请尽快完成适配,根据应用业务场景,发送不同类别的消息。

如有疑问,可以添加微信客服群进行咨询,加群方式参考文档:https://uniapp.dcloud.net.cn/unipush-v2.html#%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98

继续阅读 »

关于uni-push,即日起vivo推送基于《 消息分类说明》对本地通知进行分类管控,若应用在2026年1月1日前未完成渠道备案申请以及未设置channel_id,发送的本地通知将默认为运营消息。详情请查阅《 vivo本地通知分类管控公告》。

为了不影响您应用的重要消息推送,建议您:

  1. 在vivo开发者平台完成渠道备案申请,接入流程详见《 本地通知接入说明》。

  2. 若您发送个推在线消息是纯透传类型,并且应用端在接收消息后,调用本地通知接口展示消息到通知栏,则需要对应设置所申请的通知渠道id和渠道名称,个推服务端API无需改动;若您发送个推在线消息是通知类型,则调用个推服务端API时,设置channel_id和channel_name参数,对应填入所申请的通知渠道id和渠道名称,接口详见《公共请求参数-push_message 在线个推通道消息内容 接口》。

(注:

  1. 本地通知指应用在运行时,直接调用系统接口,不通过厂商vpush系统通道发送的通知。

  2. 透传,即只发送数据给应用端,个推并不做任何处理,应用端可以自己解析字符串进行业务逻辑的实现,比如语音播报、或者创建自定义通知等;通知,即为经个推sdk自动处理后,在通知栏以通知形式展示。)

以上请尽快完成适配,根据应用业务场景,发送不同类别的消息。

如有疑问,可以添加微信客服群进行咨询,加群方式参考文档:https://uniapp.dcloud.net.cn/unipush-v2.html#%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98

收起阅读 »

【插件】鸿蒙激励计划小助手 - 让数据统计更轻松

鸿蒙征文

一个专为华为鸿蒙激励计划开发者打造的数据统计与可视化 Chrome 浏览器插件


阶段表:

最新更新 (v2.0.0)

  • 新增日活数据统计 - 显示昨日新增、首月、次月、第三月日活数据
  • 新增趋势图表 - 点击应用展开查看日活数据可视化图表
  • 新增鸿蒙功德木鱼 - 趣味互动功能,点击木鱼积累功德

功能特点

核心功能

  • 实时数据统计 - 动态计算应用总数、激励金额、达标情况等关键指标
  • 可视化展示 - 美观的侧边栏界面,清晰展示所有统计数据
  • 分类统计 - 自动区分应用和游戏类型,分别统计
  • 阶段追踪 - 追踪应用在各个激励阶段的分布情况
  • 日活数据统计 - 展示应用的昨日新增、首月、次月、第三月日活数据
  • 趋势图表 - 可视化显示应用日活数据走势图,支持展开查看详情
  • 海报生成 - 一键生成精美的数据统计海报,支持下载分享
  • 鸿蒙功德木鱼 - 趣味互动,点击敲木鱼积累功德

界面特性

  • ✓ 现代化的渐变色设计
  • ✓ 固定在页面右侧,不影响正常浏览
  • ✓ 可收起/展开,灵活控制显示
  • ✓ 响应式布局,适配不同屏幕
  • ✓ 清晰的数据可视化卡片
  • ✓ 支持暗色主题适配

统计指标

  • 应用总数 - 显示应用和游戏的总数量
  • 预估激励 - 根据应用/游戏数量预估总激励(应用¥10,000,游戏¥2,000)
  • 已获激励 - 实际已达标的激励金额总和
  • 平均激励 - 单个应用的平均激励金额
  • 达标情况 - 基础激励、一阶段、二阶段的达标数量和比例
  • 阶段分布 - 各阶段应用数量的可视化展示
  • 日活数据 - 昨日新增日活、首月(1-30天)、次月(31-60天)、第三月(61-90天)日活统计
  • 趋势可视化 - 点击应用卡片展开,查看日活数据趋势图表

安全保障

隐私与安全

  • 本地运行 - 所有数据处理均在浏览器本地完成,无需远程服务器
  • 完全离线 - 插件不联网,不收集、不上传任何用户数据
  • 开源透明 - 所有代码完全开源,可随时审查和验证
  • 数据安全 - 数据仅存储在本地浏览器,用户完全掌控
  • 无后门风险 - 无任何第三方服务依赖,无隐私泄露风险

本插件严格遵循隐私保护原则,您的数据安全是我们的首要承诺!


安装方法

方式一:Chrome 网上应用店安装(推荐)

  1. 访问插件页面

  2. 安装插件

    • 点击"添加至 Chrome"按钮
    • 在弹出的确认对话框中点击"添加扩展程序"
    • 等待安装完成
  3. 开始使用

    • 访问华为鸿蒙激励计划数据查询页面即可自动使用

方式二:开发者模式安装(开发测试)

  1. 下载项目代码

    https://github.com/zwpro/harmonyos-incentive.git  
  2. 打开 Chrome 扩展管理页面

    • 在地址栏输入:chrome://extensions/
    • 或点击菜单 -> 更多工具 -> 扩展程序
  3. 启用开发者模式

    • 打开页面右上角的"开发者模式"开关
  4. 加载插件

    • 点击"加载已解压的扩展程序"
    • 选择项目所在的文件夹
    • 确认加载成功

使用说明

基本使用

  1. 访问目标页面

  2. 等待数据加载

    • 插件会自动在页面右侧显示侧边栏
    • 等待页面数据加载完成(或切换分页、刷新页面)
  3. 查看统计数据

    • 侧边栏自动展示所有应用的统计信息
    • 包括总数、激励金额、达标情况等

高级功能

生成统计海报

  1. 点击侧边栏中的"生成海报"按钮
  2. 等待海报生成(基于 html2canvas 技术)
  3. 在弹窗中预览海报效果
  4. 点击"下载海报"保存到本地,或直接关闭

应用详情查看

  • 每个应用卡片显示:应用名称、包名、类型、当前阶段、各阶段激励金额
  • 支持按阶段筛选和查看
  • 清晰的达标状态标识
  • 日活数据展示:显示昨日新增、首月、次月、第三月日活数据
  • 趋势图表:点击应用卡片展开,查看日活数据的可视化趋势图

鸿蒙功德木鱼

趣味互动功能,为开发之旅增添乐趣:

  • 点击木鱼图标积累功德,配合敲击动画和音效
  • 统计今日和总计敲击次数,数据本地保存

实时计算多维度统计指标:

  • 总激励 = Σ(各应用激励)
  • 预估激励 = 应用数 × 10000 + 游戏数 × 2000
  • 达标率 = 达标数量 / 总数量 × 100%

贡献指南

欢迎提交 Issue 和 Pull Request!


更新日志

v2.0.0 (2025-11-08)

  • 新增日活数据统计 - 显示昨日新增、首月、次月、第三月日活数据
  • 新增趋势图表 - 点击应用展开查看日活数据可视化图表
  • 新增鸿蒙功德木鱼 - 趣味互动功能,点击木鱼积累功德
  • 优化表格展示,增加日活数据列
  • 木鱼敲击动画、音效和特效
  • 功德数据本地持久化存储
  • 集成 Chart.js 图表库

v1.0.0 (2025-11-02)

  • 首次发布
  • 支持自动数据捕获和统计
  • 实现侧边栏可视化展示
  • 支持海报生成和下载
  • 完整的应用和游戏分类统计
  • 多维度数据指标展示

常见问题

Q: 为什么看不到数据?

A: 请确保:

  1. 已正确安装插件并刷新页面
  2. 访问的是正确的华为开发者平台页面
  3. 页面数据已加载完成(可尝试切换分页)

Q: 海报生成失败怎么办?

A: 可能原因:

  1. html2canvas 库未正确加载 - 尝试重新加载插件
  2. 浏览器兼容性问题 - 建议使用最新版 Chrome

Q: 如何更新插件?

A:

  • 从 Chrome 网上应用店安装的用户:插件会自动更新,无需手动操作
  • 开发者模式安装的用户
    1. 拉取最新代码:git pull
    2. 在扩展管理页面点击刷新图标

许可证

本项目基于 MIT License 开源。


相关链接

Chrome 网上应用店 - 安装插件
GitHub 仓库
华为鸿蒙激励计划 - 官方页面
uniapp - 官方页面
问题反馈 - 提交 Issue


交流与支持

如果这个插件对您有帮助,欢迎:

    • Star 本项目
    • 提交 Bug 报告
    • 提出新功能建议
    • 分享给其他开发者

为 HarmonyOS 开发者用心制作
Code by Uniapp

继续阅读 »

一个专为华为鸿蒙激励计划开发者打造的数据统计与可视化 Chrome 浏览器插件


阶段表:

最新更新 (v2.0.0)

  • 新增日活数据统计 - 显示昨日新增、首月、次月、第三月日活数据
  • 新增趋势图表 - 点击应用展开查看日活数据可视化图表
  • 新增鸿蒙功德木鱼 - 趣味互动功能,点击木鱼积累功德

功能特点

核心功能

  • 实时数据统计 - 动态计算应用总数、激励金额、达标情况等关键指标
  • 可视化展示 - 美观的侧边栏界面,清晰展示所有统计数据
  • 分类统计 - 自动区分应用和游戏类型,分别统计
  • 阶段追踪 - 追踪应用在各个激励阶段的分布情况
  • 日活数据统计 - 展示应用的昨日新增、首月、次月、第三月日活数据
  • 趋势图表 - 可视化显示应用日活数据走势图,支持展开查看详情
  • 海报生成 - 一键生成精美的数据统计海报,支持下载分享
  • 鸿蒙功德木鱼 - 趣味互动,点击敲木鱼积累功德

界面特性

  • ✓ 现代化的渐变色设计
  • ✓ 固定在页面右侧,不影响正常浏览
  • ✓ 可收起/展开,灵活控制显示
  • ✓ 响应式布局,适配不同屏幕
  • ✓ 清晰的数据可视化卡片
  • ✓ 支持暗色主题适配

统计指标

  • 应用总数 - 显示应用和游戏的总数量
  • 预估激励 - 根据应用/游戏数量预估总激励(应用¥10,000,游戏¥2,000)
  • 已获激励 - 实际已达标的激励金额总和
  • 平均激励 - 单个应用的平均激励金额
  • 达标情况 - 基础激励、一阶段、二阶段的达标数量和比例
  • 阶段分布 - 各阶段应用数量的可视化展示
  • 日活数据 - 昨日新增日活、首月(1-30天)、次月(31-60天)、第三月(61-90天)日活统计
  • 趋势可视化 - 点击应用卡片展开,查看日活数据趋势图表

安全保障

隐私与安全

  • 本地运行 - 所有数据处理均在浏览器本地完成,无需远程服务器
  • 完全离线 - 插件不联网,不收集、不上传任何用户数据
  • 开源透明 - 所有代码完全开源,可随时审查和验证
  • 数据安全 - 数据仅存储在本地浏览器,用户完全掌控
  • 无后门风险 - 无任何第三方服务依赖,无隐私泄露风险

本插件严格遵循隐私保护原则,您的数据安全是我们的首要承诺!


安装方法

方式一:Chrome 网上应用店安装(推荐)

  1. 访问插件页面

  2. 安装插件

    • 点击"添加至 Chrome"按钮
    • 在弹出的确认对话框中点击"添加扩展程序"
    • 等待安装完成
  3. 开始使用

    • 访问华为鸿蒙激励计划数据查询页面即可自动使用

方式二:开发者模式安装(开发测试)

  1. 下载项目代码

    https://github.com/zwpro/harmonyos-incentive.git  
  2. 打开 Chrome 扩展管理页面

    • 在地址栏输入:chrome://extensions/
    • 或点击菜单 -> 更多工具 -> 扩展程序
  3. 启用开发者模式

    • 打开页面右上角的"开发者模式"开关
  4. 加载插件

    • 点击"加载已解压的扩展程序"
    • 选择项目所在的文件夹
    • 确认加载成功

使用说明

基本使用

  1. 访问目标页面

  2. 等待数据加载

    • 插件会自动在页面右侧显示侧边栏
    • 等待页面数据加载完成(或切换分页、刷新页面)
  3. 查看统计数据

    • 侧边栏自动展示所有应用的统计信息
    • 包括总数、激励金额、达标情况等

高级功能

生成统计海报

  1. 点击侧边栏中的"生成海报"按钮
  2. 等待海报生成(基于 html2canvas 技术)
  3. 在弹窗中预览海报效果
  4. 点击"下载海报"保存到本地,或直接关闭

应用详情查看

  • 每个应用卡片显示:应用名称、包名、类型、当前阶段、各阶段激励金额
  • 支持按阶段筛选和查看
  • 清晰的达标状态标识
  • 日活数据展示:显示昨日新增、首月、次月、第三月日活数据
  • 趋势图表:点击应用卡片展开,查看日活数据的可视化趋势图

鸿蒙功德木鱼

趣味互动功能,为开发之旅增添乐趣:

  • 点击木鱼图标积累功德,配合敲击动画和音效
  • 统计今日和总计敲击次数,数据本地保存

实时计算多维度统计指标:

  • 总激励 = Σ(各应用激励)
  • 预估激励 = 应用数 × 10000 + 游戏数 × 2000
  • 达标率 = 达标数量 / 总数量 × 100%

贡献指南

欢迎提交 Issue 和 Pull Request!


更新日志

v2.0.0 (2025-11-08)

  • 新增日活数据统计 - 显示昨日新增、首月、次月、第三月日活数据
  • 新增趋势图表 - 点击应用展开查看日活数据可视化图表
  • 新增鸿蒙功德木鱼 - 趣味互动功能,点击木鱼积累功德
  • 优化表格展示,增加日活数据列
  • 木鱼敲击动画、音效和特效
  • 功德数据本地持久化存储
  • 集成 Chart.js 图表库

v1.0.0 (2025-11-02)

  • 首次发布
  • 支持自动数据捕获和统计
  • 实现侧边栏可视化展示
  • 支持海报生成和下载
  • 完整的应用和游戏分类统计
  • 多维度数据指标展示

常见问题

Q: 为什么看不到数据?

A: 请确保:

  1. 已正确安装插件并刷新页面
  2. 访问的是正确的华为开发者平台页面
  3. 页面数据已加载完成(可尝试切换分页)

Q: 海报生成失败怎么办?

A: 可能原因:

  1. html2canvas 库未正确加载 - 尝试重新加载插件
  2. 浏览器兼容性问题 - 建议使用最新版 Chrome

Q: 如何更新插件?

A:

  • 从 Chrome 网上应用店安装的用户:插件会自动更新,无需手动操作
  • 开发者模式安装的用户
    1. 拉取最新代码:git pull
    2. 在扩展管理页面点击刷新图标

许可证

本项目基于 MIT License 开源。


相关链接

Chrome 网上应用店 - 安装插件
GitHub 仓库
华为鸿蒙激励计划 - 官方页面
uniapp - 官方页面
问题反馈 - 提交 Issue


交流与支持

如果这个插件对您有帮助,欢迎:

    • Star 本项目
    • 提交 Bug 报告
    • 提出新功能建议
    • 分享给其他开发者

为 HarmonyOS 开发者用心制作
Code by Uniapp

收起阅读 »

【解决】el-form里面只有一个el-input,按回车键会刷新页面

pc

在el-form标签上添加@submit.native.prevent
例如:

<el-form @submit.native.prevent></el-form>

在el-form标签上添加@submit.native.prevent
例如:

<el-form @submit.native.prevent></el-form>

游戏上架 App Store 需要什么?从开发者资质到开心上架(Appuploader)免 Mac 上传的全流程指南

iOS

'''相比普通工具类应用,游戏上架 App Store 的要求更高,不仅需要苹果开发者资质,还涉及 内容审查、游戏版号、隐私合规 等复杂流程。

尤其在中国大陆地区,游戏上架还需要提供版号与出版备案证明。
因此,对游戏开发者来说,提前了解苹果上架要求并准备好所有资料,是节省时间、避免审核退回的关键。

本文将结合真实上架经验,带你一步步了解上架所需材料、流程与实操工具。


一、游戏上架 App Store 的整体流程

阶段 内容 说明
1 注册 Apple 开发者账号 官方必备,费用 99 美元/年
2 获取游戏版号(中国区) 国内游戏需提供出版备案号
3 准备签名证书与描述文件 用于 IPA 签名验证
4 打包生成 IPA 安装包 游戏的可执行文件
5 上传至 App Store 可使用 Appuploader 免 Mac 上传
6 提交审核与发布上线 苹果人工审核 1–3 个工作日

如果游戏面向全球发行,流程更简单;若面向中国大陆用户,则需额外提供版号信息。


二、注册 Apple Developer 开发者账号

访问 Apple Developer 官网,使用 Apple ID 登录并加入开发者计划。

账号类型 适用对象 年费 特点
个人账号 独立开发者 $99 注册简单,适合个人游戏
公司账号 游戏公司团队 $99 支持多人协作,推荐团队使用

注册公司账号时需提供营业执照和 DUNS(邓氏编码)。
注册


三、国内游戏必备:游戏版号与备案要求

若你的游戏计划上架中国大陆区 App Store,必须提供以下两项法律文件:

文件 说明 发放机构
出版备案号(ISBN) 游戏出版合法性凭证 国家新闻出版署
网络文化经营许可证 游戏上线前置审批 文化和旅游部

版号的游戏在中国区无法正式上架,但仍可在海外区发布。


四、创建签名证书与描述文件

所有游戏 App 必须通过苹果签名系统验证。

证书类型 用途
开发证书(Development) 真机调试测试
发布证书(Distribution) 上架 App Store 使用
描述文件(Provisioning Profile) 绑定 App ID 与设备信息

使用 开心上架(Appuploader) 创建证书

证书

优点:

  • 支持 Windows / Linux / macOS;
  • 免钥匙串助手与 Xcode;
  • 可多人共享使用;
  • 快速生成描述文件,减少配置错误。

五、打包生成 IPA 文件

打包 IPA 是上架前的关键步骤。

技术框架 打包方式
Unity 使用 Xcode 或云构建导出 IPA
Cocos / Cocos Creator Xcode 或命令行构建
Flutter / React Native flutter build ios --release
uni-app / HBuilderX 云打包生成 IPA

若没有 Mac,可直接通过 HBuilder 云打包 + Appuploader CLI 实现完整流程。


六、准备 App Store Connect 上架信息

登录 App Store Connect 并创建游戏项目:

项目 说明
App 名称 与游戏品牌一致
Bundle ID 与证书匹配
SKU 内部追踪编号
App 图标 1024×1024 PNG
截图 5.5”、6.5” 两种尺寸
隐私政策链接 审核必填
版号信息 国内游戏需填写 ISBN 编号

若提交的隐私政策无效链接,App 将被退回。
asc


七、上传 IPA 到 App Store

传统上传方式(Mac 设备)

  • Xcode 上传
  • Transporter App 拖拽上传
  • altool 命令行上传(已弃用)

这些方式都依赖 macOS 环境,对 Windows 或 Linux 用户极不友好。


免 Mac 方案:开心上架(Appuploader) 命令行上传

新版命令行工具支持全平台跨系统上传。

appuploader_cli -u dev@icloud.com -p xxx-xxx-xxx-xxx -c 2 -f ./build/MyGame.ipa
参数 含义
-u Apple 开发者账号
-p App 专用密码
-c 上传通道(1=旧通道,2=新通道)
-f 指定 IPA 文件路径

特点:

  • 支持 Win / Linux / Mac 全系统;
  • 上传日志实时输出;
  • 可批量上传多语言版本;
  • 可集成 CI/CD 自动化。

八、App 审核与上架发布

苹果审核流程通常包含以下阶段:

阶段 内容 时间
自动检测 系统验证 IPA 签名与元数据 数分钟
人工审核 审查内容、UI、功能、隐私政策 1~3 天
上架发布 审核通过后自动上线 立即

审核重点

  • 不得含违规内容(赌博、色情、政治)
  • 不得频繁弹出广告
  • 不得使用未经授权的音乐或素材

九、常见审核拒绝原因与解决方案

问题 原因 解决方式
审核被拒 4.0 App 闪退或不稳定 修复代码并重新打包
审核被拒 5.1 隐私声明缺失 更新 Info.plist 权限说明
审核被拒 3.1 支付体系违规 虚拟商品需使用苹果内购(IAP)
截图问题 尺寸错误或含营销语 按规范上传 5.5” + 6.5” 截图
审核延迟 版号缺失(中国区) 上传 ISBN 备案截图

十、自动化上架实战:Fastlane + 开心上架 CLI

开发团队可实现一键打包上传:

fastlane gym --scheme "MyGame" --output_directory "./build"  
appuploader_cli -u team@icloud.com -p xxx-xxx-xxx-xxx -c 2 -f ./build/MyGame.ipa

优点:

  • 自动化构建、签名、上传;
  • 支持持续集成(Jenkins / GitLab CI);
  • 无需人工干预;
  • 适合定期更新的游戏项目。

游戏上架 App Store 是一个技术、合规与内容协同的过程。
准备充分的开发者不仅能减少审核退回率,还可借助跨平台上传工具,在任意操作系统中完成上架工作。

无论你是独立游戏作者,还是跨平台开发团队,只要掌握了正确流程,上架 App Store 不再困难。

'''

继续阅读 »

'''相比普通工具类应用,游戏上架 App Store 的要求更高,不仅需要苹果开发者资质,还涉及 内容审查、游戏版号、隐私合规 等复杂流程。

尤其在中国大陆地区,游戏上架还需要提供版号与出版备案证明。
因此,对游戏开发者来说,提前了解苹果上架要求并准备好所有资料,是节省时间、避免审核退回的关键。

本文将结合真实上架经验,带你一步步了解上架所需材料、流程与实操工具。


一、游戏上架 App Store 的整体流程

阶段 内容 说明
1 注册 Apple 开发者账号 官方必备,费用 99 美元/年
2 获取游戏版号(中国区) 国内游戏需提供出版备案号
3 准备签名证书与描述文件 用于 IPA 签名验证
4 打包生成 IPA 安装包 游戏的可执行文件
5 上传至 App Store 可使用 Appuploader 免 Mac 上传
6 提交审核与发布上线 苹果人工审核 1–3 个工作日

如果游戏面向全球发行,流程更简单;若面向中国大陆用户,则需额外提供版号信息。


二、注册 Apple Developer 开发者账号

访问 Apple Developer 官网,使用 Apple ID 登录并加入开发者计划。

账号类型 适用对象 年费 特点
个人账号 独立开发者 $99 注册简单,适合个人游戏
公司账号 游戏公司团队 $99 支持多人协作,推荐团队使用

注册公司账号时需提供营业执照和 DUNS(邓氏编码)。
注册


三、国内游戏必备:游戏版号与备案要求

若你的游戏计划上架中国大陆区 App Store,必须提供以下两项法律文件:

文件 说明 发放机构
出版备案号(ISBN) 游戏出版合法性凭证 国家新闻出版署
网络文化经营许可证 游戏上线前置审批 文化和旅游部

版号的游戏在中国区无法正式上架,但仍可在海外区发布。


四、创建签名证书与描述文件

所有游戏 App 必须通过苹果签名系统验证。

证书类型 用途
开发证书(Development) 真机调试测试
发布证书(Distribution) 上架 App Store 使用
描述文件(Provisioning Profile) 绑定 App ID 与设备信息

使用 开心上架(Appuploader) 创建证书

证书

优点:

  • 支持 Windows / Linux / macOS;
  • 免钥匙串助手与 Xcode;
  • 可多人共享使用;
  • 快速生成描述文件,减少配置错误。

五、打包生成 IPA 文件

打包 IPA 是上架前的关键步骤。

技术框架 打包方式
Unity 使用 Xcode 或云构建导出 IPA
Cocos / Cocos Creator Xcode 或命令行构建
Flutter / React Native flutter build ios --release
uni-app / HBuilderX 云打包生成 IPA

若没有 Mac,可直接通过 HBuilder 云打包 + Appuploader CLI 实现完整流程。


六、准备 App Store Connect 上架信息

登录 App Store Connect 并创建游戏项目:

项目 说明
App 名称 与游戏品牌一致
Bundle ID 与证书匹配
SKU 内部追踪编号
App 图标 1024×1024 PNG
截图 5.5”、6.5” 两种尺寸
隐私政策链接 审核必填
版号信息 国内游戏需填写 ISBN 编号

若提交的隐私政策无效链接,App 将被退回。
asc


七、上传 IPA 到 App Store

传统上传方式(Mac 设备)

  • Xcode 上传
  • Transporter App 拖拽上传
  • altool 命令行上传(已弃用)

这些方式都依赖 macOS 环境,对 Windows 或 Linux 用户极不友好。


免 Mac 方案:开心上架(Appuploader) 命令行上传

新版命令行工具支持全平台跨系统上传。

appuploader_cli -u dev@icloud.com -p xxx-xxx-xxx-xxx -c 2 -f ./build/MyGame.ipa
参数 含义
-u Apple 开发者账号
-p App 专用密码
-c 上传通道(1=旧通道,2=新通道)
-f 指定 IPA 文件路径

特点:

  • 支持 Win / Linux / Mac 全系统;
  • 上传日志实时输出;
  • 可批量上传多语言版本;
  • 可集成 CI/CD 自动化。

八、App 审核与上架发布

苹果审核流程通常包含以下阶段:

阶段 内容 时间
自动检测 系统验证 IPA 签名与元数据 数分钟
人工审核 审查内容、UI、功能、隐私政策 1~3 天
上架发布 审核通过后自动上线 立即

审核重点

  • 不得含违规内容(赌博、色情、政治)
  • 不得频繁弹出广告
  • 不得使用未经授权的音乐或素材

九、常见审核拒绝原因与解决方案

问题 原因 解决方式
审核被拒 4.0 App 闪退或不稳定 修复代码并重新打包
审核被拒 5.1 隐私声明缺失 更新 Info.plist 权限说明
审核被拒 3.1 支付体系违规 虚拟商品需使用苹果内购(IAP)
截图问题 尺寸错误或含营销语 按规范上传 5.5” + 6.5” 截图
审核延迟 版号缺失(中国区) 上传 ISBN 备案截图

十、自动化上架实战:Fastlane + 开心上架 CLI

开发团队可实现一键打包上传:

fastlane gym --scheme "MyGame" --output_directory "./build"  
appuploader_cli -u team@icloud.com -p xxx-xxx-xxx-xxx -c 2 -f ./build/MyGame.ipa

优点:

  • 自动化构建、签名、上传;
  • 支持持续集成(Jenkins / GitLab CI);
  • 无需人工干预;
  • 适合定期更新的游戏项目。

游戏上架 App Store 是一个技术、合规与内容协同的过程。
准备充分的开发者不仅能减少审核退回率,还可借助跨平台上传工具,在任意操作系统中完成上架工作。

无论你是独立游戏作者,还是跨平台开发团队,只要掌握了正确流程,上架 App Store 不再困难。

'''

收起阅读 »

【鸿蒙征文】已解决隐私弹窗问题,华为应用商店已经 4 次驳回我的应用上线

鸿蒙征文

一. 前言

不得不说,华为应用商店的审核还是过于严格了,最近提交的新版本应用又被拒绝了!已经提交了4个版本了,再这样下去,我就要崩溃了!

好多人已经对华为的这项审核怨言颇深了!

其实,华为审核严格是一方面,另一方面主要在于 uni-app,由于该应用是使用 uni-app 开发并打包的,所以受限于 uni-app 框架,而它给添加了太多没有用的东西,都集成在框架中,并且删除不掉。

而像本次华为应用商店驳回审核,需要修改的地方已经给罗列的很清楚了,只需要按照他们的说明整改即可,最起码我们能通过自己修改代码就可以完成,不用找官方解决,比较简单。

所以有两个问题需要整改:

  • 隐私政策声明
  • 申请权限时需告知用户使用目的

隐私政策文件的整改很简单,以 com.bun.miitmdid 为例,需要在隐私政策中添加相关说明即可。本篇文章我们直接进行申请权限时的整改,接下来进入正文!

二. 修改权限申请逻辑

前面提到华为应用商店的审核还是过于严格了,其实相比较其他国内应用市场(小米/VIVO/OPPO)来说,区别就在于用户在申请敏感权限时,需同步告知用户申请该权限的目的。对于申请的权限,都必须有明确、合理的使用场景和功能说明,禁止诱导或误导用户授权。

如下图所示:

目前应用内申请权限时是这样的:

接下来我们要进行整改,整改完成后是这样的:

三. 权限申请

1. 使用 plus.android.requestPermissions

统一通过 plus.android.requestPermissions 向系统请求权限,如果权限属于危险权限并且用户没有授权则会弹出系统提示框由用户授权确认。

plus.android.requestPermissions(permissions, successCallback, errorCallback)

2. 参数说明

  • permissions: 申请的权限列表,权限列表参考 Android 官方列表

  • successCallback:  申请权限成功回调函数,参考 AndroidSuccessCallback,返回申请权限的结果,可能被用户允许,回调函数的参数 event 包含以下属性:

    • granted - Array[String]字符串数组,已获取权限列表;
    • deniedPresent - Array[String]字符串数据,已拒绝(临时)的权限列表;
    • deniedAlways - Array[String]字符串数据,永久拒绝的权限列表。
  • errorCallback:  申请权限失败回调函数,参考 AndroidErrorCallback

    • 通常传入参数错误时触发此回调。

注意:Android 系统 6+版本(API 等级 23+),并且必须设置 targetSdkVersion>=23。

如果已经授权或被用户拒绝则返回结果。 授权结果在 successCallback 回调参数中可获取。

为了便于统一申请权限,封装为以下方法,可直接复制使用!

// Android权限查询  
export function requestAndroidPermission(permissionID) {  
  return new Promise((resolve, reject) => {  
    plus.android.requestPermissions(  
      // 理论上支持多个权限同时查询,但实际上本函数封装只处理了一个权限的情况。有需要的可自行扩展封装  
      [permissionID],  
      function (resultObj) {  
        var result = 0  
        for (var i = 0; i < resultObj.granted.length; i++) {  
          var grantedPermission = resultObj.granted[i]  
          console.log('已获取的权限:' + grantedPermission)  
          result = 1  
        }  
        for (var i = 0; i < resultObj.deniedPresent.length; i++) {  
          var deniedPresentPermission = resultObj.deniedPresent[i]  
          console.log('拒绝本次申请的权限:' + deniedPresentPermission)  
          result = 0  
        }  
        for (var i = 0; i < resultObj.deniedAlways.length; i++) {  
          var deniedAlwaysPermission = resultObj.deniedAlways[i]  
          console.log('永久拒绝申请的权限:' + deniedAlwaysPermission)  
          result = -1  
        }  
        uni.setStorageSync('permisionStatus_' + permissionID, result)  

        resolve(result)  
        // 若所需权限被拒绝,则打开APP设置界面,可以在APP设置界面打开相应权限  
        if (result != 1) {  
          // gotoAppPermissionSetting()  
          uni.showModal({  
            content: '权限已经被拒绝,请前往APP设置界面打开相应权限'  
          })  
        }  
      },  
      function (error) {  
        console.log('申请权限错误:' + error.code + ' = ' + error.message)  
        resolve({  
          code: error.code,  
          message: error.message  
        })  
      }  
    )  
  })  
}

此文件来源于 https://ext.dcloud.net.cn/plugin?id=594 部分片段

使用方式:

requestAndroidPermission('android.permission.WRITE_EXTERNAL_STORAGE').then(  
  result => {  
    // result 表示:1已获取,0已拒绝,-1永久拒绝。可根据返回码定向处理  
  }  
)

四. 原生弹窗

接下来我们应该构造一个弹窗类 NativePopup 用于在应用内申请权限时弹窗告知用户,说明申请权限的使用目的。

在这里,App 端使用 plus.nativeObj.view 绘制原生内容,参考:uni-app 中使用 5+界面控件plus.nativeObj.view 规范

代码如下,可直接复制使用!

export class NativePopup {  
  constructor(options = {}) {  
    this.sysInfo = uni.getSystemInfoSync()  

    const {  
      bgColor = '#fff',  
      titleColor = '#000',  
      contentColor = '#272727'  
    } = options  

    this.bgColor = bgColor  
    this.titleColor = titleColor  
    this.contentColor = contentColor  
  }  

  createPopup = () => {  
    const { statusBarHeight, screenWidth } = this.sysInfo  

    const popupView = new plus.nativeObj.View('popupView', {  
      top: 0,  
      left: 0,  
      width: screenWidth,  
      height: 110 + statusBarHeight + 'px'  
      // backgroundColor: 'blue' // debug  
    })  

    popupView.addEventListener('click', this.close)  

    const bgPadding = 15  

    popupView.drawRect(  
      {  
        color: 'rgba(0, 0, 0, 0.1)',  
        radius: '10px'  
      },  
      {  
        top: statusBarHeight + 7 + 'px',  
        left: bgPadding - 2 + 'px',  
        width: screenWidth - bgPadding * 2 + 4 + 'px',  
        height: '100px'  
      }  
    )  

    popupView.drawRect(  
      {  
        color: this.bgColor,  
        radius: '10px'  
      },  
      {  
        top: statusBarHeight + 5 + 'px',  
        left: bgPadding + 'px',  
        width: screenWidth - bgPadding * 2 + 'px',  
        height: '100px'  
      }  
    )  

    const padding = 10  

    popupView.drawText(  
      this.title,  
      {  
        top: statusBarHeight + 10 + 'px',  
        left: padding + bgPadding + 'px',  
        height: '30px',  
        width: screenWidth - bgPadding * 2 - padding * 2 + 'px'  
      },  
      {  
        size: '16px',  
        weight: 'bold',  
        align: 'left',  
        color: this.titleColor  
      },  
      {  
        onClick: function (e) {  
          console.log(e)  
        }  
      }  
    )  

    popupView.drawText(  
      this.content,  
      {  
        top: statusBarHeight + 40 + 'px',  
        height: '60px',  
        left: padding + bgPadding + 'px',  
        width: screenWidth - bgPadding * 2 - padding * 2 + 'px'  
      },  
      {  
        size: '14px',  
        align: 'left',  
        color: this.contentColor,  
        whiteSpace: 'normal'  
      }  
    )  

    this.popupView = popupView  

    return popupView  
  }  

  show = (options = {}) => {  
    this.close()  

    const { title = '权限申请说明', content = '' } = options  
    this.title = title  
    this.content = content  

    this.createPopup()  

    this.popupView.show()  
  }  

  close = () => {  
    this.popupView && this.popupView.close()  
  }  
}  

export const popup = new NativePopup()

使用方式如下:

import { popup } from './nativePopup.js'  
// 显示  
popup.show({  
  title: '权限申请说明',  
  content: '为了xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'  
})  
// 关闭  
popup.close()

五. 监听权限申请

createRequestPermissionListener

华为应用商店审核时要求:APP在调用终端权限时,应同步告知用户申请该权限的目的,可使用 uni.createRequestPermissionListener(),在 app.vue 里全局监听。

在 Android 平台,可使用该 API 监听应用权限申请确认框的弹出和关闭。不管是哪处的业务代码在申请权限,当弹出和关闭权限申请确认框时均会触发本监听事件。

创建监听对象后,返回 RequestPermissionListener,然后调起 onConfirmonComplete

  • 当权限申请的确认框在手机端弹出时,会触发 onConfirm,回调中会以数组方式提供权限名称列表。
  • 当权限申请的确认框被用户关闭后,会触发 onComplete

所以,通过监听权限申请,在 onConfirm 回调中弹窗,可以实现不改动业务代码,全局处理权限弹窗问题!

以下代码已经声明了大部分默认权限申请说明信息,如有新增或调整,可以进行更改或传入!

import { popup } from './nativePopup.js'  
import permisionUtil from './permission.js'  

let permissionListener = null  

const prefix = 'permisionStatus_'  
const { uniPlatform, platform, osAndroidAPILevel } = uni.getSystemInfoSync()  

const log = (...args) => {  
  console.log(...args)  
}  

// 默认权限申请说明信息,可以按照以下形式进行拓展  
const defaultPermissionExplainMap = {  
  'android.permission.BLUETOOTH_SCAN': {  
    title: '蓝牙扫描权限申请说明',  
    content: '应用需要扫描附近的蓝牙设备,以便进行连接或数据传输。'  
  },  
  'android.permission.BLUETOOTH_CONNECT': {  
    title: '蓝牙连接权限申请说明',  
    content: '应用需要连接蓝牙设备,以便提供音频播放或数据通信功能。'  
  },  
  'android.permission.READ_MEDIA_IMAGE': {  
    title: '读取图片权限申请说明',  
    content: '应用需要访问您的图片库,以便加载和选择照片。'  
  }  
}  

export const createRequestPermissionListener = (permissionExplainMap = {}) => {  
  if (uniPlatform != 'app' || platform != 'android') return  

  if (typeof permissionExplainMap != 'object')  
    throw Error('permissionExplainMap 类型错误')  

  permissionListener =  
    permissionListener || uni.createRequestPermissionListener()  

  permissionListener.onRequest(e => {  
    log('onRequest', JSON.stringify(e))  
  })  

  permissionListener.onConfirm(e => {  
    const [permissionName] = e  

    const status = uni.getStorageSync(prefix + permissionName)  
    log('onConfirm permissionName', permissionName, status)  
    const content =  
      permissionExplainMap[permissionName] ||  
      defaultPermissionExplainMap[permissionName]  
    if (!status && content) popup.show(content)  
  })  

  permissionListener.onComplete(e => {  
    const [permissionName] = e  

    const status = uni.getStorageSync(prefix + permissionName)  
    log('onComplete permissionName', permissionName, status)  
    popup.close()  
  })  
}  

export const stopRequestPermissionListener = () => {  
  permissionListener && permissionListener.stop()  
}  

export { permisionUtil }

六. 用法说明

1. 引入全局监听

App.vue 的生命周期中开始监听,停止监听。

import { createRequestPermissionListener } from '@/uni_modules/permission/index.js'  
export default {  
  onLaunch() {  
    createRequestPermissionListener()  
  },  
  onExit() {  
    stopRequestPermissionListener()  
  }  
}

2. 申请权限

在应用使用权限之前进行检测权限申请,例如,在进行扫码前先申请相机权限:

注意:可以不进行主动申请权限,因为在全局已经做了监听,弹窗会自动弹出!但为了特殊情况(比如在使用原生的权限申请操作,无法监听到),建议在这种情况下提前申请权限!

import { requestAndroidPermission } from '@/uni_modules/permission/index.js'  

async requestPermission() {  
    const status = await requestAndroidPermission('android.permission.CAMERA')  
    if (status != 1) {  
        // 权限被拒绝  
        return  
    }  
}

七. 注意事项

  • 如果权限已经申请并且允许之后,onConfirm不会触发。
  • 如果同时申请多个权限时,onComplete可能会触发多次。
  • 只能监听通过 uniapp 或 plus 提供的权限申请时弹出提示,如果你使用原生的权限申请操作,无法监听到!

八. 总结

本文主要介绍了如何解决华为应用市场审核的问题,主要涉及两个方面:

  1. 隐私政策声明

    • 需要在隐私政策中明确声明应用使用的 SDK 信息
    • 包括 SDK 名称、包名、使用目的、使用的权限、涉及的个人信息等
    • 以 com.bun.miitmdid 为例,需要在隐私政策中添加相关说明
  2. 权限申请优化

    • 在申请敏感权限时,需要同步告知用户申请该权限的目的
    • 提供了完整的权限申请解决方案:
      • 封装了 Android 权限申请方法
      • 实现了原生弹窗组件用于权限说明
      • 通过全局监听权限申请,自动显示权限说明
      • 提供了常用权限的默认说明文案
    • 特别针对华为应用市场做了渠道包判断
  3. 技术实现要点

    • 使用 plus.android.requestPermissions 进行权限申请
    • 使用 plus.nativeObj.view 实现原生弹窗
    • 使用 uni.createRequestPermissionListener 监听权限申请
    • 通过 plus.runtime.channel 判断应用渠道

通过以上优化,可以有效解决华为应用市场的审核问题,提升应用的用户体验和合规性。同时,这些优化措施也可以作为其他应用市场的参考,提高应用的整体质量。

参考文档

华为应用隐私合规问题小学堂

Android 平台各功能模块隐私合规协议

Android 平台权限列表参考

Android 平台权限申请 requestPermissions

Android 平台监听权限申请

Android 平台自定义渠道包

本文对应的源码已发布到插件市场,可直接使用(下载插件并导入HBuilderX):【DCloud插件市场】监听权限申请,解决华为应用商店上架问题

继续阅读 »

一. 前言

不得不说,华为应用商店的审核还是过于严格了,最近提交的新版本应用又被拒绝了!已经提交了4个版本了,再这样下去,我就要崩溃了!

好多人已经对华为的这项审核怨言颇深了!

其实,华为审核严格是一方面,另一方面主要在于 uni-app,由于该应用是使用 uni-app 开发并打包的,所以受限于 uni-app 框架,而它给添加了太多没有用的东西,都集成在框架中,并且删除不掉。

而像本次华为应用商店驳回审核,需要修改的地方已经给罗列的很清楚了,只需要按照他们的说明整改即可,最起码我们能通过自己修改代码就可以完成,不用找官方解决,比较简单。

所以有两个问题需要整改:

  • 隐私政策声明
  • 申请权限时需告知用户使用目的

隐私政策文件的整改很简单,以 com.bun.miitmdid 为例,需要在隐私政策中添加相关说明即可。本篇文章我们直接进行申请权限时的整改,接下来进入正文!

二. 修改权限申请逻辑

前面提到华为应用商店的审核还是过于严格了,其实相比较其他国内应用市场(小米/VIVO/OPPO)来说,区别就在于用户在申请敏感权限时,需同步告知用户申请该权限的目的。对于申请的权限,都必须有明确、合理的使用场景和功能说明,禁止诱导或误导用户授权。

如下图所示:

目前应用内申请权限时是这样的:

接下来我们要进行整改,整改完成后是这样的:

三. 权限申请

1. 使用 plus.android.requestPermissions

统一通过 plus.android.requestPermissions 向系统请求权限,如果权限属于危险权限并且用户没有授权则会弹出系统提示框由用户授权确认。

plus.android.requestPermissions(permissions, successCallback, errorCallback)

2. 参数说明

  • permissions: 申请的权限列表,权限列表参考 Android 官方列表

  • successCallback:  申请权限成功回调函数,参考 AndroidSuccessCallback,返回申请权限的结果,可能被用户允许,回调函数的参数 event 包含以下属性:

    • granted - Array[String]字符串数组,已获取权限列表;
    • deniedPresent - Array[String]字符串数据,已拒绝(临时)的权限列表;
    • deniedAlways - Array[String]字符串数据,永久拒绝的权限列表。
  • errorCallback:  申请权限失败回调函数,参考 AndroidErrorCallback

    • 通常传入参数错误时触发此回调。

注意:Android 系统 6+版本(API 等级 23+),并且必须设置 targetSdkVersion>=23。

如果已经授权或被用户拒绝则返回结果。 授权结果在 successCallback 回调参数中可获取。

为了便于统一申请权限,封装为以下方法,可直接复制使用!

// Android权限查询  
export function requestAndroidPermission(permissionID) {  
  return new Promise((resolve, reject) => {  
    plus.android.requestPermissions(  
      // 理论上支持多个权限同时查询,但实际上本函数封装只处理了一个权限的情况。有需要的可自行扩展封装  
      [permissionID],  
      function (resultObj) {  
        var result = 0  
        for (var i = 0; i < resultObj.granted.length; i++) {  
          var grantedPermission = resultObj.granted[i]  
          console.log('已获取的权限:' + grantedPermission)  
          result = 1  
        }  
        for (var i = 0; i < resultObj.deniedPresent.length; i++) {  
          var deniedPresentPermission = resultObj.deniedPresent[i]  
          console.log('拒绝本次申请的权限:' + deniedPresentPermission)  
          result = 0  
        }  
        for (var i = 0; i < resultObj.deniedAlways.length; i++) {  
          var deniedAlwaysPermission = resultObj.deniedAlways[i]  
          console.log('永久拒绝申请的权限:' + deniedAlwaysPermission)  
          result = -1  
        }  
        uni.setStorageSync('permisionStatus_' + permissionID, result)  

        resolve(result)  
        // 若所需权限被拒绝,则打开APP设置界面,可以在APP设置界面打开相应权限  
        if (result != 1) {  
          // gotoAppPermissionSetting()  
          uni.showModal({  
            content: '权限已经被拒绝,请前往APP设置界面打开相应权限'  
          })  
        }  
      },  
      function (error) {  
        console.log('申请权限错误:' + error.code + ' = ' + error.message)  
        resolve({  
          code: error.code,  
          message: error.message  
        })  
      }  
    )  
  })  
}

此文件来源于 https://ext.dcloud.net.cn/plugin?id=594 部分片段

使用方式:

requestAndroidPermission('android.permission.WRITE_EXTERNAL_STORAGE').then(  
  result => {  
    // result 表示:1已获取,0已拒绝,-1永久拒绝。可根据返回码定向处理  
  }  
)

四. 原生弹窗

接下来我们应该构造一个弹窗类 NativePopup 用于在应用内申请权限时弹窗告知用户,说明申请权限的使用目的。

在这里,App 端使用 plus.nativeObj.view 绘制原生内容,参考:uni-app 中使用 5+界面控件plus.nativeObj.view 规范

代码如下,可直接复制使用!

export class NativePopup {  
  constructor(options = {}) {  
    this.sysInfo = uni.getSystemInfoSync()  

    const {  
      bgColor = '#fff',  
      titleColor = '#000',  
      contentColor = '#272727'  
    } = options  

    this.bgColor = bgColor  
    this.titleColor = titleColor  
    this.contentColor = contentColor  
  }  

  createPopup = () => {  
    const { statusBarHeight, screenWidth } = this.sysInfo  

    const popupView = new plus.nativeObj.View('popupView', {  
      top: 0,  
      left: 0,  
      width: screenWidth,  
      height: 110 + statusBarHeight + 'px'  
      // backgroundColor: 'blue' // debug  
    })  

    popupView.addEventListener('click', this.close)  

    const bgPadding = 15  

    popupView.drawRect(  
      {  
        color: 'rgba(0, 0, 0, 0.1)',  
        radius: '10px'  
      },  
      {  
        top: statusBarHeight + 7 + 'px',  
        left: bgPadding - 2 + 'px',  
        width: screenWidth - bgPadding * 2 + 4 + 'px',  
        height: '100px'  
      }  
    )  

    popupView.drawRect(  
      {  
        color: this.bgColor,  
        radius: '10px'  
      },  
      {  
        top: statusBarHeight + 5 + 'px',  
        left: bgPadding + 'px',  
        width: screenWidth - bgPadding * 2 + 'px',  
        height: '100px'  
      }  
    )  

    const padding = 10  

    popupView.drawText(  
      this.title,  
      {  
        top: statusBarHeight + 10 + 'px',  
        left: padding + bgPadding + 'px',  
        height: '30px',  
        width: screenWidth - bgPadding * 2 - padding * 2 + 'px'  
      },  
      {  
        size: '16px',  
        weight: 'bold',  
        align: 'left',  
        color: this.titleColor  
      },  
      {  
        onClick: function (e) {  
          console.log(e)  
        }  
      }  
    )  

    popupView.drawText(  
      this.content,  
      {  
        top: statusBarHeight + 40 + 'px',  
        height: '60px',  
        left: padding + bgPadding + 'px',  
        width: screenWidth - bgPadding * 2 - padding * 2 + 'px'  
      },  
      {  
        size: '14px',  
        align: 'left',  
        color: this.contentColor,  
        whiteSpace: 'normal'  
      }  
    )  

    this.popupView = popupView  

    return popupView  
  }  

  show = (options = {}) => {  
    this.close()  

    const { title = '权限申请说明', content = '' } = options  
    this.title = title  
    this.content = content  

    this.createPopup()  

    this.popupView.show()  
  }  

  close = () => {  
    this.popupView && this.popupView.close()  
  }  
}  

export const popup = new NativePopup()

使用方式如下:

import { popup } from './nativePopup.js'  
// 显示  
popup.show({  
  title: '权限申请说明',  
  content: '为了xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'  
})  
// 关闭  
popup.close()

五. 监听权限申请

createRequestPermissionListener

华为应用商店审核时要求:APP在调用终端权限时,应同步告知用户申请该权限的目的,可使用 uni.createRequestPermissionListener(),在 app.vue 里全局监听。

在 Android 平台,可使用该 API 监听应用权限申请确认框的弹出和关闭。不管是哪处的业务代码在申请权限,当弹出和关闭权限申请确认框时均会触发本监听事件。

创建监听对象后,返回 RequestPermissionListener,然后调起 onConfirmonComplete

  • 当权限申请的确认框在手机端弹出时,会触发 onConfirm,回调中会以数组方式提供权限名称列表。
  • 当权限申请的确认框被用户关闭后,会触发 onComplete

所以,通过监听权限申请,在 onConfirm 回调中弹窗,可以实现不改动业务代码,全局处理权限弹窗问题!

以下代码已经声明了大部分默认权限申请说明信息,如有新增或调整,可以进行更改或传入!

import { popup } from './nativePopup.js'  
import permisionUtil from './permission.js'  

let permissionListener = null  

const prefix = 'permisionStatus_'  
const { uniPlatform, platform, osAndroidAPILevel } = uni.getSystemInfoSync()  

const log = (...args) => {  
  console.log(...args)  
}  

// 默认权限申请说明信息,可以按照以下形式进行拓展  
const defaultPermissionExplainMap = {  
  'android.permission.BLUETOOTH_SCAN': {  
    title: '蓝牙扫描权限申请说明',  
    content: '应用需要扫描附近的蓝牙设备,以便进行连接或数据传输。'  
  },  
  'android.permission.BLUETOOTH_CONNECT': {  
    title: '蓝牙连接权限申请说明',  
    content: '应用需要连接蓝牙设备,以便提供音频播放或数据通信功能。'  
  },  
  'android.permission.READ_MEDIA_IMAGE': {  
    title: '读取图片权限申请说明',  
    content: '应用需要访问您的图片库,以便加载和选择照片。'  
  }  
}  

export const createRequestPermissionListener = (permissionExplainMap = {}) => {  
  if (uniPlatform != 'app' || platform != 'android') return  

  if (typeof permissionExplainMap != 'object')  
    throw Error('permissionExplainMap 类型错误')  

  permissionListener =  
    permissionListener || uni.createRequestPermissionListener()  

  permissionListener.onRequest(e => {  
    log('onRequest', JSON.stringify(e))  
  })  

  permissionListener.onConfirm(e => {  
    const [permissionName] = e  

    const status = uni.getStorageSync(prefix + permissionName)  
    log('onConfirm permissionName', permissionName, status)  
    const content =  
      permissionExplainMap[permissionName] ||  
      defaultPermissionExplainMap[permissionName]  
    if (!status && content) popup.show(content)  
  })  

  permissionListener.onComplete(e => {  
    const [permissionName] = e  

    const status = uni.getStorageSync(prefix + permissionName)  
    log('onComplete permissionName', permissionName, status)  
    popup.close()  
  })  
}  

export const stopRequestPermissionListener = () => {  
  permissionListener && permissionListener.stop()  
}  

export { permisionUtil }

六. 用法说明

1. 引入全局监听

App.vue 的生命周期中开始监听,停止监听。

import { createRequestPermissionListener } from '@/uni_modules/permission/index.js'  
export default {  
  onLaunch() {  
    createRequestPermissionListener()  
  },  
  onExit() {  
    stopRequestPermissionListener()  
  }  
}

2. 申请权限

在应用使用权限之前进行检测权限申请,例如,在进行扫码前先申请相机权限:

注意:可以不进行主动申请权限,因为在全局已经做了监听,弹窗会自动弹出!但为了特殊情况(比如在使用原生的权限申请操作,无法监听到),建议在这种情况下提前申请权限!

import { requestAndroidPermission } from '@/uni_modules/permission/index.js'  

async requestPermission() {  
    const status = await requestAndroidPermission('android.permission.CAMERA')  
    if (status != 1) {  
        // 权限被拒绝  
        return  
    }  
}

七. 注意事项

  • 如果权限已经申请并且允许之后,onConfirm不会触发。
  • 如果同时申请多个权限时,onComplete可能会触发多次。
  • 只能监听通过 uniapp 或 plus 提供的权限申请时弹出提示,如果你使用原生的权限申请操作,无法监听到!

八. 总结

本文主要介绍了如何解决华为应用市场审核的问题,主要涉及两个方面:

  1. 隐私政策声明

    • 需要在隐私政策中明确声明应用使用的 SDK 信息
    • 包括 SDK 名称、包名、使用目的、使用的权限、涉及的个人信息等
    • 以 com.bun.miitmdid 为例,需要在隐私政策中添加相关说明
  2. 权限申请优化

    • 在申请敏感权限时,需要同步告知用户申请该权限的目的
    • 提供了完整的权限申请解决方案:
      • 封装了 Android 权限申请方法
      • 实现了原生弹窗组件用于权限说明
      • 通过全局监听权限申请,自动显示权限说明
      • 提供了常用权限的默认说明文案
    • 特别针对华为应用市场做了渠道包判断
  3. 技术实现要点

    • 使用 plus.android.requestPermissions 进行权限申请
    • 使用 plus.nativeObj.view 实现原生弹窗
    • 使用 uni.createRequestPermissionListener 监听权限申请
    • 通过 plus.runtime.channel 判断应用渠道

通过以上优化,可以有效解决华为应用市场的审核问题,提升应用的用户体验和合规性。同时,这些优化措施也可以作为其他应用市场的参考,提高应用的整体质量。

参考文档

华为应用隐私合规问题小学堂

Android 平台各功能模块隐私合规协议

Android 平台权限列表参考

Android 平台权限申请 requestPermissions

Android 平台监听权限申请

Android 平台自定义渠道包

本文对应的源码已发布到插件市场,可直接使用(下载插件并导入HBuilderX):【DCloud插件市场】监听权限申请,解决华为应用商店上架问题

收起阅读 »

uniapp+vue3 setup跨三端酒店预订小程序模板【h5+小程序+app端】

vite vue3 uni-app uniapp

uniapp-vue3-hotel:一款全新自研的uni-app+vue3 setup+pinia2+uv-ui搭建跨端仿携程/同程旅游app酒店预约系统模板。提供了首页、酒店预订搜索、列表/详情、订单、聊天客服消息、我的等页面模块。支持编译到H5+小程序+APP端

使用技术

  • 开发工具:HbuilderX 4.84
  • 技术框架:uni-app+vite5+vue3
  • 状态管理:pinia2
  • UI组件库:uni-ui+uv-ui(uniapp+vue3组件库)
  • 弹框组件:uv3-popup(基于uniapp+vue3多端弹窗组件)
  • 自定义组件:uv3-navbar导航条+uv3-tabbar菜单栏
  • 缓存技术:pinia-plugin-unistorage
  • 编译支持:web+小程序+app端

项目框架结构

使用uniapp+vite5搭建项目,vue3 setup语法开发。

目前uni-vue3-hotel酒店预订项目已经发布到我的原创作品小铺。

uniapp+vue3+pinia2+uvui跨多端酒店预订app系统

想要了解更多项目的详细介绍,可以去看看下面这篇文章。

最新版uni-app+vue3+uv-ui跨端仿携程酒店预订模板【H5+小程序+App端】

热文推荐

最新版uni-app+vue3+uv-ui跨三端仿微信app聊天应用【h5+小程序+app端】
最新版uniapp+vue3+uv-ui跨三端短视频+直播+聊天【H5+小程序+App端】
uniapp-vue3-os手机oa系统|uni-app+vue3跨三端os后台管理模板
原创uniapp+vue3+deepseek+uv-ui跨端实战仿deepseek/豆包流式ai聊天对话助手。
Tauri2.8+Vue3聊天系统|vite7+tauri2+element-plus客户端仿微信聊天程序
Tauri2-Vite7Admin客户端管理后台|tauri2.9+vue3+element-plus后台系统
Electron38-Vue3OS客户端OS系统|vite7+electron38+arco桌面os后台管理
electron38-admin桌面端后台|Electron38+Vue3+ElementPlus管理系统
Electron38-Wechat电脑端聊天|vite7+electron38仿微信桌面端聊天系统
vue3-webseek网页版AI问答|Vite6+DeepSeek+Arco流式ai聊天打字效果
Flutter3-MacOS桌面OS系统|flutter3.32+window_manager客户端OS模板
最新研发flutter3.27+bitsdojo_window+getx客户端仿微信聊天Exe应用
最新版Flutter3.32+Dart3.8跨平台仿微信app聊天界面|朋友圈
Electron35-DeepSeek桌面端AI系统|vue3.5+electron+arco客户端ai模板

继续阅读 »

uniapp-vue3-hotel:一款全新自研的uni-app+vue3 setup+pinia2+uv-ui搭建跨端仿携程/同程旅游app酒店预约系统模板。提供了首页、酒店预订搜索、列表/详情、订单、聊天客服消息、我的等页面模块。支持编译到H5+小程序+APP端

使用技术

  • 开发工具:HbuilderX 4.84
  • 技术框架:uni-app+vite5+vue3
  • 状态管理:pinia2
  • UI组件库:uni-ui+uv-ui(uniapp+vue3组件库)
  • 弹框组件:uv3-popup(基于uniapp+vue3多端弹窗组件)
  • 自定义组件:uv3-navbar导航条+uv3-tabbar菜单栏
  • 缓存技术:pinia-plugin-unistorage
  • 编译支持:web+小程序+app端

项目框架结构

使用uniapp+vite5搭建项目,vue3 setup语法开发。

目前uni-vue3-hotel酒店预订项目已经发布到我的原创作品小铺。

uniapp+vue3+pinia2+uvui跨多端酒店预订app系统

想要了解更多项目的详细介绍,可以去看看下面这篇文章。

最新版uni-app+vue3+uv-ui跨端仿携程酒店预订模板【H5+小程序+App端】

热文推荐

最新版uni-app+vue3+uv-ui跨三端仿微信app聊天应用【h5+小程序+app端】
最新版uniapp+vue3+uv-ui跨三端短视频+直播+聊天【H5+小程序+App端】
uniapp-vue3-os手机oa系统|uni-app+vue3跨三端os后台管理模板
原创uniapp+vue3+deepseek+uv-ui跨端实战仿deepseek/豆包流式ai聊天对话助手。
Tauri2.8+Vue3聊天系统|vite7+tauri2+element-plus客户端仿微信聊天程序
Tauri2-Vite7Admin客户端管理后台|tauri2.9+vue3+element-plus后台系统
Electron38-Vue3OS客户端OS系统|vite7+electron38+arco桌面os后台管理
electron38-admin桌面端后台|Electron38+Vue3+ElementPlus管理系统
Electron38-Wechat电脑端聊天|vite7+electron38仿微信桌面端聊天系统
vue3-webseek网页版AI问答|Vite6+DeepSeek+Arco流式ai聊天打字效果
Flutter3-MacOS桌面OS系统|flutter3.32+window_manager客户端OS模板
最新研发flutter3.27+bitsdojo_window+getx客户端仿微信聊天Exe应用
最新版Flutter3.32+Dart3.8跨平台仿微信app聊天界面|朋友圈
Electron35-DeepSeek桌面端AI系统|vue3.5+electron+arco客户端ai模板

收起阅读 »

【弧形导航栏】中间凸起按钮和消息未读角标,支持鸿蒙

鸿蒙征文

Tabbar 组件使用说明

概述

自定义底部导航栏组件,支持中间凸起按钮和消息未读角标功能。

插件地址:https://ext.dcloud.net.cn/plugin?id=25774

功能特性

  • ✅ 5 个 tab 页面切换(健康、消息、守护、家庭、我的)
  • ✅ 中间守护 tab 凸起设计
  • ✅ 支持消息未读角标(红色圆点+白色数字)
  • ✅ 支持本地切换模式和路由切换模式
  • ✅ 平滑切换动画

Props

useLocalSwitch

  • 类型: Boolean
  • 默认值: false
  • 说明: 是否使用本地切换模式。如果为 true,切换 tab 时会发出 tab-change 事件而不是进行路由跳转

badges

  • 类型: Object
  • 默认值: {}
  • 说明: 未读消息角标配置,key 为页面路径,value 为未读数
  • 示例:
{  
  '/pages/message/message': 5,  
  '/pages/health/health': 2,  
  '/pages/family/family': 10,  
  '/pages/my/my': 99  
}

Events

tab-change

  • 参数: (pagePath: string) - 切换到的页面路径
  • 触发时机: 当 useLocalSwitchtrue 时,点击 tab 触发
  • 说明: 用于父组件监听 tab 切换事件

基本使用

1. 不带角标(默认模式)

<template>  
  <view>  
    <Tabbar />  
  </view>  
</template>  

<script setup>  
import Tabbar from "@/components/tabbar/tabbar.vue";  
</script>

2. 带消息未读角标

<template>  
  <view>  
    <Tabbar :badges="badgeData" />  
  </view>  
</template>  

<script setup>  
import { ref } from "vue";  
import Tabbar from "@/components/tabbar/tabbar.vue";  

const badgeData = ref({  
  "/pages/message/message": 5, // 消息页显示 5  
  "/pages/health/health": 2, // 健康页显示 2  
  "/pages/family/family": 120, // 家庭页显示 99+ (超过99)  
});  
</script>

3. 本地切换模式(用于自定义页面切换)

<template>  
  <view>  
    <Tabbar  
      :use-local-switch="true"  
      :badges="badgeData"  
      @tab-change="handleTabChange"  
    />  
  </view>  
</template>  

<script setup>  
import { ref } from "vue";  
import Tabbar from "@/components/tabbar/tabbar.vue";  

const badgeData = ref({  
  "/pages/message/message": 3,  
});  

const handleTabChange = (pagePath) => {  
  console.log("切换到页面:", pagePath);  
  // 在这里处理自定义的页面切换逻辑  
};  
</script>

4. 动态更新角标数量

<template>  
  <view>  
    <Tabbar :badges="badgeData" />  
  </view>  
</template>  

<script setup>  
import { ref, onMounted } from "vue";  
import Tabbar from "@/components/tabbar/tabbar.vue";  

const badgeData = ref({  
  "/pages/message/message": 0,  
});  

// 模拟接收新消息  
onMounted(() => {  
  // 3秒后更新消息数  
  setTimeout(() => {  
    badgeData.value["/pages/message/message"] = 5;  
  }, 3000);  
});  
</script>

5. 结合 Pinia 状态管理

<template>  
  <view>  
    <Tabbar :badges="badgeData" />  
  </view>  
</template>  

<script setup>  
import { computed } from "vue";  
import { useMessageStore } from "@/stores/message";  
import Tabbar from "@/components/tabbar/tabbar.vue";  

const messageStore = useMessageStore();  

// 从store中获取未读消息数  
const badgeData = computed(() => ({  
  "/pages/message/message": messageStore.unreadCount,  
  "/pages/health/health": messageStore.healthNoticeCount,  
  "/pages/family/family": messageStore.familyNoticeCount,  
}));  
</script>

角标显示规则

  1. 数字范围:

    • 0: 不显示角标
    • 1-99: 显示实际数字
    • >99: 显示 "99+"
  2. 样式规范:

    • 背景色: #FF3B30 (红色)
    • 文字色: #FFFFFF (白色)
    • 位置: 图标右上角
    • 形状: 圆形(固定尺寸)
    • 尺寸: 16px x 16px(固定宽高)
  3. 显示位置:

    • 左侧 tab(健康、消息): 支持角标 ✅
    • 中间 tab(守护): 不支持角标 ❌
    • 右侧 tab(家庭、我的): 支持角标 ✅

注意事项

  1. 中间凸起的守护 tab 不支持角标显示
  2. 角标数据是响应式的,可以实时更新
  3. 角标仅在数字大于 0 时显示
  4. 建议使用 Pinia 进行全局的未读消息管理
  5. 在 HarmonyOS 系统中测试确保角标显示正常

插件地址:https://ext.dcloud.net.cn/plugin?id=25774

欢迎交流讨论~

继续阅读 »

Tabbar 组件使用说明

概述

自定义底部导航栏组件,支持中间凸起按钮和消息未读角标功能。

插件地址:https://ext.dcloud.net.cn/plugin?id=25774

功能特性

  • ✅ 5 个 tab 页面切换(健康、消息、守护、家庭、我的)
  • ✅ 中间守护 tab 凸起设计
  • ✅ 支持消息未读角标(红色圆点+白色数字)
  • ✅ 支持本地切换模式和路由切换模式
  • ✅ 平滑切换动画

Props

useLocalSwitch

  • 类型: Boolean
  • 默认值: false
  • 说明: 是否使用本地切换模式。如果为 true,切换 tab 时会发出 tab-change 事件而不是进行路由跳转

badges

  • 类型: Object
  • 默认值: {}
  • 说明: 未读消息角标配置,key 为页面路径,value 为未读数
  • 示例:
{  
  '/pages/message/message': 5,  
  '/pages/health/health': 2,  
  '/pages/family/family': 10,  
  '/pages/my/my': 99  
}

Events

tab-change

  • 参数: (pagePath: string) - 切换到的页面路径
  • 触发时机: 当 useLocalSwitchtrue 时,点击 tab 触发
  • 说明: 用于父组件监听 tab 切换事件

基本使用

1. 不带角标(默认模式)

<template>  
  <view>  
    <Tabbar />  
  </view>  
</template>  

<script setup>  
import Tabbar from "@/components/tabbar/tabbar.vue";  
</script>

2. 带消息未读角标

<template>  
  <view>  
    <Tabbar :badges="badgeData" />  
  </view>  
</template>  

<script setup>  
import { ref } from "vue";  
import Tabbar from "@/components/tabbar/tabbar.vue";  

const badgeData = ref({  
  "/pages/message/message": 5, // 消息页显示 5  
  "/pages/health/health": 2, // 健康页显示 2  
  "/pages/family/family": 120, // 家庭页显示 99+ (超过99)  
});  
</script>

3. 本地切换模式(用于自定义页面切换)

<template>  
  <view>  
    <Tabbar  
      :use-local-switch="true"  
      :badges="badgeData"  
      @tab-change="handleTabChange"  
    />  
  </view>  
</template>  

<script setup>  
import { ref } from "vue";  
import Tabbar from "@/components/tabbar/tabbar.vue";  

const badgeData = ref({  
  "/pages/message/message": 3,  
});  

const handleTabChange = (pagePath) => {  
  console.log("切换到页面:", pagePath);  
  // 在这里处理自定义的页面切换逻辑  
};  
</script>

4. 动态更新角标数量

<template>  
  <view>  
    <Tabbar :badges="badgeData" />  
  </view>  
</template>  

<script setup>  
import { ref, onMounted } from "vue";  
import Tabbar from "@/components/tabbar/tabbar.vue";  

const badgeData = ref({  
  "/pages/message/message": 0,  
});  

// 模拟接收新消息  
onMounted(() => {  
  // 3秒后更新消息数  
  setTimeout(() => {  
    badgeData.value["/pages/message/message"] = 5;  
  }, 3000);  
});  
</script>

5. 结合 Pinia 状态管理

<template>  
  <view>  
    <Tabbar :badges="badgeData" />  
  </view>  
</template>  

<script setup>  
import { computed } from "vue";  
import { useMessageStore } from "@/stores/message";  
import Tabbar from "@/components/tabbar/tabbar.vue";  

const messageStore = useMessageStore();  

// 从store中获取未读消息数  
const badgeData = computed(() => ({  
  "/pages/message/message": messageStore.unreadCount,  
  "/pages/health/health": messageStore.healthNoticeCount,  
  "/pages/family/family": messageStore.familyNoticeCount,  
}));  
</script>

角标显示规则

  1. 数字范围:

    • 0: 不显示角标
    • 1-99: 显示实际数字
    • >99: 显示 "99+"
  2. 样式规范:

    • 背景色: #FF3B30 (红色)
    • 文字色: #FFFFFF (白色)
    • 位置: 图标右上角
    • 形状: 圆形(固定尺寸)
    • 尺寸: 16px x 16px(固定宽高)
  3. 显示位置:

    • 左侧 tab(健康、消息): 支持角标 ✅
    • 中间 tab(守护): 不支持角标 ❌
    • 右侧 tab(家庭、我的): 支持角标 ✅

注意事项

  1. 中间凸起的守护 tab 不支持角标显示
  2. 角标数据是响应式的,可以实时更新
  3. 角标仅在数字大于 0 时显示
  4. 建议使用 Pinia 进行全局的未读消息管理
  5. 在 HarmonyOS 系统中测试确保角标显示正常

插件地址:https://ext.dcloud.net.cn/plugin?id=25774

欢迎交流讨论~

收起阅读 »