HBuilderX

HBuilderX

极客开发工具
uni-app

uni-app

开发一次,多端覆盖
uniCloud

uniCloud

云开发平台
HTML5+

HTML5+

增强HTML5的功能体验
MUI

MUI

上万Star的前端框架

鸿蒙上架提审驳回理由常见解决方案

鸿蒙

本文用于采集应用上架驳回理由,定期汇总到 uniapp 鸿蒙文档中。常见的问题可在 https://uniapp.dcloud.net.cn/tutorial/harmony/runbuild.html 进行查询。如果你遇到了除此之外的驳回理由,可留言。

tab 走焦

近期有用户反馈,应用无法响应键盘 tab 按键

> 应用/元服务中的走焦事件能够响应tab键或方向键切换。https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/device-compatible

在电脑、平台、折叠电脑上属于规则级别,也就是强制要求。

临时规避方案1,自行明确当前是否需要支持平板,如果目前不需要可在代码中搜索 devicetype,保持为 phone ,在 uniapp 后台、agc 后台表格里只勾选手机,也就是避免支持平板。

解决方案2:你可在指定的组件中,添加 tabindex,从而让功能区支持 tab 切换。目前规则中未明确要求支持回车按键,可忽略处理,也可在组件中,使用 renderjs ,在 renderjs 的 mounted/unmounted 里监听、取消监听 addEventListener keydown 相关事件,主动触发 click

后续会针对性优化。

继续阅读 »

本文用于采集应用上架驳回理由,定期汇总到 uniapp 鸿蒙文档中。常见的问题可在 https://uniapp.dcloud.net.cn/tutorial/harmony/runbuild.html 进行查询。如果你遇到了除此之外的驳回理由,可留言。

tab 走焦

近期有用户反馈,应用无法响应键盘 tab 按键

> 应用/元服务中的走焦事件能够响应tab键或方向键切换。https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/device-compatible

在电脑、平台、折叠电脑上属于规则级别,也就是强制要求。

临时规避方案1,自行明确当前是否需要支持平板,如果目前不需要可在代码中搜索 devicetype,保持为 phone ,在 uniapp 后台、agc 后台表格里只勾选手机,也就是避免支持平板。

解决方案2:你可在指定的组件中,添加 tabindex,从而让功能区支持 tab 切换。目前规则中未明确要求支持回车按键,可忽略处理,也可在组件中,使用 renderjs ,在 renderjs 的 mounted/unmounted 里监听、取消监听 addEventListener keydown 相关事件,主动触发 click

后续会针对性优化。

收起阅读 »

经验分享 鸿蒙隐私弹窗如何处理

鸿蒙征文

基本背景

鸿蒙应用上架需要配置隐私协议、用户协议。这部分内容可参考 鸿蒙如何设置隐私协议弹窗

这里做技术实现说明。

方案一:自行绘制

目前已经上架的 hellouniapp应用,和一部分已上架用户案例,都是使用自己弹窗完成的。可使用页面组件弹窗、也可制作单独的页面进行路由跳转,用户的同意状态存储 storage 中,后续启动根据参数来动态判断。

这里相对容易,不做进一步说明。

注意:元服务中只能选择托管协议,鸿蒙应用可在托管协议、自定义填写 URL 二选一,如果选择自定义 URL,只能使用方案一自定绘制。

方案二:官方隐私协议托管

鸿蒙的 AGC 后台有隐私协议托管功能,通过填写标准化的文档,完成隐私协议的填写,按照要求填写内容,生成协议,在上架提审过程中选择即可。具体参鸿蒙文档 隐私管理服务

好处:

  • 隐私协议按照要求填写,不会因为格式出错被驳回,正确填写相关 sdk、权限说明即可。
  • 网页由鸿蒙托管访问有保障。
  • 可使用鸿蒙 api 唤起系统弹窗,视觉风格和鸿蒙统一。

整体步骤如下

1 后台填写隐私协议

得到 url 地址

2. 添加 debug 代码

代码中,编辑 harmony-configs/entry/src/main/module.json5 按照文档要求,添加三个字段,分别是

  • appgallery_privacy_hosted
  • appgallery_privacy_link_privacy_statement
  • appgallery_privacy_link_user_agreement

填写这个三个弹窗的作用是模拟线上正式环境,用于测试通过 api 唤起弹窗的效果,务必注意,并不是为了实现自动弹窗

3. 编写 uts 插件

编写 uts 插件,完成弹窗,这里提供代码片段,后续提供 uts 插件市场链接。

新建 uts 插件,比如 hamrony-privacy-dialog ,编辑 uni_modules/harmony-privacy-dialog/utssdk/app-harmony/index.uts

填写下面代码,这段代码导出了三个方法,和官方的 ets 代码完全一致,检查隐私协议信息、获取签署状态、主动唤起隐私弹窗。页面中引入代码调用即可。

import { privacyManager } from '@kit.AppGalleryKit';  
// import { hilog } from '@kit.PerformanceAnalysisKit';  
import { BusinessError } from '@kit.BasicServicesKit';  

export const getInfo = () => {  
    let err = ''  
    try {  
        let appPrivacyManageInfo : privacyManager.AppPrivacyMgmtInfo = privacyManager.getAppPrivacyMgmtInfo();  
        console.info(0, 'TAG', "Succeeded in getting AppPrivacyManageInfo type: " + appPrivacyManageInfo["type"]);  
        let privacyLinkInfoArray : privacyManager.AppPrivacyLink[] = appPrivacyManageInfo.privacyInfo;  
        console.info(0, 'TAG', "Succeeded in getting AppPrivacyManageInfo size = " + privacyLinkInfoArray.length);  
        for (let i = 0; i < privacyLinkInfoArray.length; i++) {  
            console.info(0, 'TAG', "Succeeded in getting AppPrivacyManageInfo type = " + privacyLinkInfoArray[i]["type"] + ", version = " + privacyLinkInfoArray[i]["versionCode"] + ", url = " + privacyLinkInfoArray[i]["url"]);  
        }  
    } catch (error) {  
        err = error.message  
        console.error(0, 'TAG', "GetAppPrivacyManageInfoPublic exception code: " + error.code + ", exception message: " + error.message);  
    }  
    return err  
}  

export const getStatus = () => {  
    try {  
        let appPrivacyResults : privacyManager.AppPrivacyResult[] = privacyManager.getAppPrivacyResult();  
        console.info(0, 'TAG', "Succeeded in getting AppPrivacyResult size = " + appPrivacyResults.length);  
        for (let i = 0; i < appPrivacyResults.length; i++) {  
            console.info(0, 'TAG', "Succeeded in getting AppPrivacyResult type = " + appPrivacyResults[i]["type"] + ", version = " + appPrivacyResults[i]["versionCode"] + ", result = " + appPrivacyResults[i]["result"]);  
        }  
    } catch (error) {  
        console.error(0, 'TAG', "GetAppPrivacyResultPublic exception code: " + error.code + ", exception message: " + error.message);  
    }  
}  
export const requestPrivacy = () => {  
    try {  
        const uiContext = UTSHarmony.getUIAbilityContext()  
        // const uiContext = this.getUIContext().getHostContext() as common.UIAbilityContext;  
        privacyManager.requestAppPrivacyConsent(uiContext).then((consentResult : privacyManager.ConsentResult) => {  
            let appPrivacyResults : privacyManager.AppPrivacyResult[] = consentResult["results"];  
            for (let i = 0; i < appPrivacyResults.length; i++) {  
                console.info(0, 'TAG', "GetAppPrivacyResult type = " + appPrivacyResults[i]["type"] + ", version = " + appPrivacyResults[i]["versionCode"] + ", result = " + appPrivacyResults[i]["result"] + ", signingTimeStamp = " + appPrivacyResults[i]["signingTimeStamp"]);  
            }  
        }).catch((error : BusinessError<Object>) => {  
            console.error(0, 'TAG', `requestAppPrivacyConsent failed, Code: ${error.code}, message: ${error.message}`);  
        });  
    } catch (error) {  
        console.error(0, 'TAG', "requestAppPrivacyConsent exception code: " + error.code + ", exception message: " + error.message);  
    }  
}

4 页面中引入

    import {  
        getInfo,  
        getStatus,requestPrivacy  
    } from '@/uni_modules/harmony-privacy-dialog'
继续阅读 »

基本背景

鸿蒙应用上架需要配置隐私协议、用户协议。这部分内容可参考 鸿蒙如何设置隐私协议弹窗

这里做技术实现说明。

方案一:自行绘制

目前已经上架的 hellouniapp应用,和一部分已上架用户案例,都是使用自己弹窗完成的。可使用页面组件弹窗、也可制作单独的页面进行路由跳转,用户的同意状态存储 storage 中,后续启动根据参数来动态判断。

这里相对容易,不做进一步说明。

注意:元服务中只能选择托管协议,鸿蒙应用可在托管协议、自定义填写 URL 二选一,如果选择自定义 URL,只能使用方案一自定绘制。

方案二:官方隐私协议托管

鸿蒙的 AGC 后台有隐私协议托管功能,通过填写标准化的文档,完成隐私协议的填写,按照要求填写内容,生成协议,在上架提审过程中选择即可。具体参鸿蒙文档 隐私管理服务

好处:

  • 隐私协议按照要求填写,不会因为格式出错被驳回,正确填写相关 sdk、权限说明即可。
  • 网页由鸿蒙托管访问有保障。
  • 可使用鸿蒙 api 唤起系统弹窗,视觉风格和鸿蒙统一。

整体步骤如下

1 后台填写隐私协议

得到 url 地址

2. 添加 debug 代码

代码中,编辑 harmony-configs/entry/src/main/module.json5 按照文档要求,添加三个字段,分别是

  • appgallery_privacy_hosted
  • appgallery_privacy_link_privacy_statement
  • appgallery_privacy_link_user_agreement

填写这个三个弹窗的作用是模拟线上正式环境,用于测试通过 api 唤起弹窗的效果,务必注意,并不是为了实现自动弹窗

3. 编写 uts 插件

编写 uts 插件,完成弹窗,这里提供代码片段,后续提供 uts 插件市场链接。

新建 uts 插件,比如 hamrony-privacy-dialog ,编辑 uni_modules/harmony-privacy-dialog/utssdk/app-harmony/index.uts

填写下面代码,这段代码导出了三个方法,和官方的 ets 代码完全一致,检查隐私协议信息、获取签署状态、主动唤起隐私弹窗。页面中引入代码调用即可。

import { privacyManager } from '@kit.AppGalleryKit';  
// import { hilog } from '@kit.PerformanceAnalysisKit';  
import { BusinessError } from '@kit.BasicServicesKit';  

export const getInfo = () => {  
    let err = ''  
    try {  
        let appPrivacyManageInfo : privacyManager.AppPrivacyMgmtInfo = privacyManager.getAppPrivacyMgmtInfo();  
        console.info(0, 'TAG', "Succeeded in getting AppPrivacyManageInfo type: " + appPrivacyManageInfo["type"]);  
        let privacyLinkInfoArray : privacyManager.AppPrivacyLink[] = appPrivacyManageInfo.privacyInfo;  
        console.info(0, 'TAG', "Succeeded in getting AppPrivacyManageInfo size = " + privacyLinkInfoArray.length);  
        for (let i = 0; i < privacyLinkInfoArray.length; i++) {  
            console.info(0, 'TAG', "Succeeded in getting AppPrivacyManageInfo type = " + privacyLinkInfoArray[i]["type"] + ", version = " + privacyLinkInfoArray[i]["versionCode"] + ", url = " + privacyLinkInfoArray[i]["url"]);  
        }  
    } catch (error) {  
        err = error.message  
        console.error(0, 'TAG', "GetAppPrivacyManageInfoPublic exception code: " + error.code + ", exception message: " + error.message);  
    }  
    return err  
}  

export const getStatus = () => {  
    try {  
        let appPrivacyResults : privacyManager.AppPrivacyResult[] = privacyManager.getAppPrivacyResult();  
        console.info(0, 'TAG', "Succeeded in getting AppPrivacyResult size = " + appPrivacyResults.length);  
        for (let i = 0; i < appPrivacyResults.length; i++) {  
            console.info(0, 'TAG', "Succeeded in getting AppPrivacyResult type = " + appPrivacyResults[i]["type"] + ", version = " + appPrivacyResults[i]["versionCode"] + ", result = " + appPrivacyResults[i]["result"]);  
        }  
    } catch (error) {  
        console.error(0, 'TAG', "GetAppPrivacyResultPublic exception code: " + error.code + ", exception message: " + error.message);  
    }  
}  
export const requestPrivacy = () => {  
    try {  
        const uiContext = UTSHarmony.getUIAbilityContext()  
        // const uiContext = this.getUIContext().getHostContext() as common.UIAbilityContext;  
        privacyManager.requestAppPrivacyConsent(uiContext).then((consentResult : privacyManager.ConsentResult) => {  
            let appPrivacyResults : privacyManager.AppPrivacyResult[] = consentResult["results"];  
            for (let i = 0; i < appPrivacyResults.length; i++) {  
                console.info(0, 'TAG', "GetAppPrivacyResult type = " + appPrivacyResults[i]["type"] + ", version = " + appPrivacyResults[i]["versionCode"] + ", result = " + appPrivacyResults[i]["result"] + ", signingTimeStamp = " + appPrivacyResults[i]["signingTimeStamp"]);  
            }  
        }).catch((error : BusinessError<Object>) => {  
            console.error(0, 'TAG', `requestAppPrivacyConsent failed, Code: ${error.code}, message: ${error.message}`);  
        });  
    } catch (error) {  
        console.error(0, 'TAG', "requestAppPrivacyConsent exception code: " + error.code + ", exception message: " + error.message);  
    }  
}

4 页面中引入

    import {  
        getInfo,  
        getStatus,requestPrivacy  
    } from '@/uni_modules/harmony-privacy-dialog'
收起阅读 »

【鸿蒙征文】:重拾儿时趣味的古诗 App 的开发过程记录

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

一款无广告重拾儿时趣味的古诗 App 的开发过程记录

一、🌈缘起:让古诗 “活” 起来,而非躺在角落🌈

做这款 App 的初衷,藏在三个真实需求里:
一次偶然的寻找碰上鸿蒙的星光闪烁:翻找学生时代的语文诗词,想重拾那份诗词氛围时,书本早已不见踪影,此时便想在手机上寻找这类APP,市面古诗 App 要么广告扎堆,要么功能枯燥,实在感受不到当年的那种氛围;
身边朋友想重拾古诗,却缺合适的背诵工具,也没有专属交流渠道(总不能天天在朋友圈发古诗);
古诗的意境与字词之美,不该只靠 “死记硬背”—— 结合互动、朗读、释义,让大家 “懂了再背”,才是真正的文化传承。
所以核心需求很明确:做一款 “不枯燥、能互动、够纯粹” 的古诗工具。既要满足学习需求,又要兼顾趣味性,还得适配鸿蒙系统(毕竟鸿蒙用户越来越多,生态也日趋完善)。

二、💡技术选型:为啥 “锁死” UniAPP?💡

选技术栈时纠结了 3 天,最终敲定 UniAPP,全靠 “实用主义”:
排除鸿蒙原生开发:适配鸿蒙虽丝滑,但单人开发要重新学原生语法,还无法兼顾其他平台,效率太低;
排除其他跨平台框架:要么对鸿蒙适配不完善,要么插件生态薄弱,像诗词朗读、音频上传等功能得手动造轮子;

选择 UniAPP 的核心原因:
跨平台省心:一套代码可打包鸿蒙 App,后续扩展安卓、iOS 版本无需重构,对个人开发者极度友好;

  • 鸿蒙适配 “自带 buff”:HBuilderX 提供现成打包模板和专属适配插件,不用手动修改大量配置;
  • 生态够全:uniCloud 云开发、音频组件、数据库插件,刚好满足古诗存储、朗读上传、接龙匹配等核心需求;
  • 学习成本低:有微信小程序开发基础,UniAPP 的 Vue 语法无缝衔接,1 周就能上手。
    简单说:用 UniAPP 开发鸿蒙,就像 “站在巨人肩膀上”—— 不用从零学鸿蒙原生,又能享受鸿蒙系统特性,对非专业开发者太友好了!

三、⚠️踩坑实录:鸿蒙适配那些 “磨人的小妖精”⚠️

开发前以为 “跨平台 = 一路顺畅”,结果真正适配鸿蒙时,还是踩了不少坑,分享 3 个最印象深刻的:

权限申请:鸿蒙的 “规矩” 和其他平台不一样
做朗读广场时,需要调用手机麦克风录音、存储音频文件。一开始直接复用了其他平台的权限代码,结果在鸿蒙上直接报错 —— 录音功能打不开!

  • 坑点:鸿蒙的 “媒体权限” 需要单独申请,而且必须配置ohos.permission.READ_MEDIA和ohos.permission.WRITE_MEDIA,还得在代码里动态弹窗说明 “为啥要要权限”(比如 “需要录音才能上传你的朗读作品”),光说 “需要权限” 会被系统拒绝;而且必须在鸿蒙的AGC后台进行同步申请该权限,因为该权限属于受限权限;而且!!!如果是使用的鸿蒙自家的隐私托管服务,必须要在隐私托管服务中同步该权限的声明
  • 解决:使用uts插件配置权限;然后在AGC后台同步申请该受限权限通过率直接 100%。
    // #ifdef APP-HARMONY  
    import "@/uni_modules/harmony-permissions"  
    // #endif 
 {  
  "module": {  
    "name": "uni_modules__harmony_permissions",  
    "type": "har",  
    "deviceTypes": [  
      "default",  
      "tablet",  
      "2in1"  
    ],  
    "requestPermissions": [  
      {  
        "name": "ohos.permission.INTERNET"  
      },{  
        "name": "ohos.permission.WRITE_MEDIA",  
        "reason": "$string:media_desc",  
        "usedScene": {"when": "inuse"}  
      },{  
        "name": "ohos.permission.READ_MEDIA",  
        "reason": "$string:media_desc",  
        "usedScene": {"when": "inuse"}  
      }  
    ]  
  }  
}

服务卡片适配:桌面卡片让古诗 “触手可及”
鸿蒙的桌面卡片是个大亮点,我想做个 “每日一句” 卡片,用户不用打开 App,桌面就能看到古诗。

  • 坑点:UniAPP 打包鸿蒙应用时,卡片的尺寸适配很麻烦,比如 3x2、2x2 的卡片布局会错乱,而且卡片数据刷新需要和主 App 同步;
  • 解决:用 UniAPP 提供的uni-app x编译模式(专门优化鸿蒙适配),卡片布局用flex弹性盒,固定宽高比;数据同步用uniCloud实时数据库,主 App 更新 “每日一句” 后,卡片自动刷新,不用用户手动操作 —— 现在不少用户说 “每天解锁手机先看一眼古诗,感觉很治愈”。

音频播放:后台播放老是 “断档”
朗读广场的核心是 “听别人读古诗”,但一开始用户反馈 “退到后台就停了”,体验很差。

  • 坑点:鸿蒙对后台音频播放有严格限制,普通的audio组件在后台会被系统暂停;
  • 解决:集成了鸿蒙的AVPlayer SDK(UniAPP 可以直接调用鸿蒙原生 SDK),在manifest.json里配置 “后台音频权限”,同时用 UniAPP 的onBackground生命周期监听,退后台时自动切换到鸿蒙原生播放器,现在后台播放能稳定运行,用户还能边听古诗边刷微信~

四、📦功能落地:每个模块都是 “心头好”📦

结合古诗的核心需求,几个功能模块都是 “边踩坑边优化” 出来的,挑 3 个重点说:

  1. 背一背:用 “艾宾浩斯曲线” 让背诵不费脑
    一开始只是简单做了 “列表 + 背诵打卡”,但用户反馈 “背了就忘”。后来用 UniAPP 的storage存储用户背诵记录,结合艾宾浩斯遗忘曲线,在对应时间点推送提醒(比如第 1 天、第 3 天、第 7 天),还加了 “遮字默写” 功能 —— 点击诗句能隐藏关键字,像考试一样检验效果。

    • 小技巧:用 UniAPP 的uni.createPushMessage调用鸿蒙的通知权限,提醒文案特意用古诗意境,比如 “床前明月光,今天该复习啦~”,比干巴巴的 “请背诵” 更讨喜。
      <view class="poem-content">  
      <block v-if="poemStore.showFullContent">  
        <text v-for="(line, idx) in poemStore.currentPoem.content" :key="idx" class="full-line">{{ line }}</text>  
      </block>  
      <block v-else>  
        <text v-for="(line, idx) in poemStore.currentPoem.content" :key="idx" class="hide-line">□□□□□□□</text>  
      </block>  
      </view>  
      <button class="toggle-btn" @click="poemStore.toggleFullContent">  
      {{ poemStore.showFullContent ? '切换到背诵模式' : '查看完整诗句' }}  
      </button>
      <script setup lang="ts">  
      import { usePoemStore } from '@/stores/poemStore';  
      import { useRouter } from 'vue-router';  
      const poemStore = usePoemStore();  
      const router = useRouter();  
      const goBack = () => {  
      router.back();  
      };  
      const handleCollect = () => {  
      if (poemStore.currentPoem) {  
      poemStore.toggleCollect(poemStore.currentPoem.id);  
      }  
      };  
      </script>
  2. 诗词 / 成语接龙:云开发让匹配更流畅
    接龙功能需要实时匹配用户输入的诗句 / 成语,还要校验是否正确、有没有重复。一开始用本地数据库,结果数据量太大(收录了 2 万 + 古诗、1 万 + 成语),App 启动变慢。

    export interface Poem {  
    id: number;  
    title: string; // 诗名  
    author: string; // 作者(含朝代)  
    content: string[]; // 诗句数组  
    category: string; // 分类(如山水、边塞)  
    isCollected: boolean; // 是否收藏  
    }
    • 优化:改用uniCloud云开发,把诗词库、成语库存到云端,用云函数做匹配校验(比如输入 “举头望明月”,云函数自动查询以 “月” 开头的诗句),App 端只负责展示和输入,启动速度从 3 秒降到 1 秒,接龙延迟也几乎感知不到。
  3. 主题颜色:贴合古风的 “颜值小心思”
    考虑到用户可能在不同场景使用(比如晚上背古诗要护眼),做了 3 种古风主题:黛青(默认)、朱砂(暖调)、月白(护眼)。

    • 实现:用 UniAPP 的vuex管理主题状态,结合鸿蒙的 “系统深色模式”,用户切换系统主题时,App 自动适配对应的古风配色 —— 比如系统开深色模式,App 自动切月白主题,字体加粗,保护视力。
      import { defineStore } from 'pinia';  
      import { Poem } from '@/types/poem';  
      const mockPoems: Poem[] = [  
      {  
      id: 1,  
      title: '望庐山瀑布',  
      author: '唐·李白',  
      content: ['日照香炉生紫烟', '遥看瀑布挂前川', '飞流直下三千尺', '疑是银河落九天'],  
      category: '山水',  
      isCollected: false  
      },  
      {  
      id: 2,  
      title: '静夜思',  
      author: '唐·李白',  
      content: ['床前明月光', '疑是地上霜', '举头望明月', '低头思故乡'],  
      category: '思乡',  
      isCollected: false  
      },  
      {  
      id: 3,  
      title: '春晓',  
      author: '唐·孟浩然',  
      content: ['春眠不觉晓', '处处闻啼鸟', '夜来风雨声', '花落知多少'],  
      category: '春景',  
      isCollected: false  
      }  
      ];  
      export const usePoemStore = defineStore('poem', {  
      state: () => ({  
      poems: mockPoems as Poem[], // 古诗列表  
      currentPoem: null as Poem | null, // 当前选中古诗  
      showFullContent: false // 详情页是否显示完整诗句(背诵模式控制)  
      }),  
      actions: {  
      // 选中古诗  
      selectPoem(poemId: number) {  
      this.currentPoem = this.poems.find(poem => poem.id === poemId) || null;  
      this.showFullContent = false; // 进入背诵模式,默认隐藏完整诗句  
      },  
      // 切换收藏状态  
      toggleCollect(poemId: number) {  
      const poem = this.poems.find(poem => poem.id === poemId);  
      if (poem) poem.isCollected = !poem.isCollected;  
      },  
      // 切换完整诗句显示(背诵/查看模式)  
      toggleFullContent() {  
      this.showFullContent = !this.showFullContent;  
      }  
      }  
      });

五、✅ 后续功能想法 ✅

  • “想要更多主题颜色”→ 新增 “竹绿”“藤黄” 2 种配色;
  • “朗读广场想给作品点赞”→ 加了点赞功能,用uniCloud存储互动数据;
  • “背古诗想有奖励”→ 做了 “背诵勋章”,集齐 5 个勋章能解锁古诗插画壁纸。

虽然还未成功上架市场,但在开发中寻找用户体验过程中,最让我感动的是一条评论:“我家娃以前不爱背古诗,现在每天打卡接龙,还会给同学分享自己的朗读作品,谢谢开发者让传统文化变有趣~” 这种时候,觉得熬夜改 bug、反复适配都值了!

六、🌟星光不负:技术与文化的双向奔赴🌟

从一开始的 “想做个古诗工具”,到现在的 “让自己更了解古诗并爱上它”,用 UniAPP 开发鸿蒙 App 的这 3 个月,不仅让我摸清了跨平台开发的套路,更懂了 “技术是为需求服务”—— 不是堆功能,而是让应用真正有用起来、用得爽。
回头看,选择 UniAPP 是最正确的决定:它让我不用纠结原生语法,能把更多精力放在 “怎么让古诗更有趣” 上;而鸿蒙的开放能力(云开发、云调试、HarmonyOS SDK、云测试)等,又让 App 的体验更上一层楼。就像古诗里说的 “行则将至”,技术之路没有捷径,但只要朝着目标一步步走,星光总会照亮前路。
接下来,我还想加 AR 功能(用鸿蒙的 AR SDK,扫描实景生成古诗意境)、古诗合唱(朗读广场支持多人合拍),让传统文化通过技术 “活” 起来、“火” 起来。如果你也在做 UniAPP + 鸿蒙开发,或者对古诗 App 有更多想法,欢迎一起交流~ 毕竟,码向未来的路上,有人同行更精彩!

继续阅读 »

一款无广告重拾儿时趣味的古诗 App 的开发过程记录

一、🌈缘起:让古诗 “活” 起来,而非躺在角落🌈

做这款 App 的初衷,藏在三个真实需求里:
一次偶然的寻找碰上鸿蒙的星光闪烁:翻找学生时代的语文诗词,想重拾那份诗词氛围时,书本早已不见踪影,此时便想在手机上寻找这类APP,市面古诗 App 要么广告扎堆,要么功能枯燥,实在感受不到当年的那种氛围;
身边朋友想重拾古诗,却缺合适的背诵工具,也没有专属交流渠道(总不能天天在朋友圈发古诗);
古诗的意境与字词之美,不该只靠 “死记硬背”—— 结合互动、朗读、释义,让大家 “懂了再背”,才是真正的文化传承。
所以核心需求很明确:做一款 “不枯燥、能互动、够纯粹” 的古诗工具。既要满足学习需求,又要兼顾趣味性,还得适配鸿蒙系统(毕竟鸿蒙用户越来越多,生态也日趋完善)。

二、💡技术选型:为啥 “锁死” UniAPP?💡

选技术栈时纠结了 3 天,最终敲定 UniAPP,全靠 “实用主义”:
排除鸿蒙原生开发:适配鸿蒙虽丝滑,但单人开发要重新学原生语法,还无法兼顾其他平台,效率太低;
排除其他跨平台框架:要么对鸿蒙适配不完善,要么插件生态薄弱,像诗词朗读、音频上传等功能得手动造轮子;

选择 UniAPP 的核心原因:
跨平台省心:一套代码可打包鸿蒙 App,后续扩展安卓、iOS 版本无需重构,对个人开发者极度友好;

  • 鸿蒙适配 “自带 buff”:HBuilderX 提供现成打包模板和专属适配插件,不用手动修改大量配置;
  • 生态够全:uniCloud 云开发、音频组件、数据库插件,刚好满足古诗存储、朗读上传、接龙匹配等核心需求;
  • 学习成本低:有微信小程序开发基础,UniAPP 的 Vue 语法无缝衔接,1 周就能上手。
    简单说:用 UniAPP 开发鸿蒙,就像 “站在巨人肩膀上”—— 不用从零学鸿蒙原生,又能享受鸿蒙系统特性,对非专业开发者太友好了!

三、⚠️踩坑实录:鸿蒙适配那些 “磨人的小妖精”⚠️

开发前以为 “跨平台 = 一路顺畅”,结果真正适配鸿蒙时,还是踩了不少坑,分享 3 个最印象深刻的:

权限申请:鸿蒙的 “规矩” 和其他平台不一样
做朗读广场时,需要调用手机麦克风录音、存储音频文件。一开始直接复用了其他平台的权限代码,结果在鸿蒙上直接报错 —— 录音功能打不开!

  • 坑点:鸿蒙的 “媒体权限” 需要单独申请,而且必须配置ohos.permission.READ_MEDIA和ohos.permission.WRITE_MEDIA,还得在代码里动态弹窗说明 “为啥要要权限”(比如 “需要录音才能上传你的朗读作品”),光说 “需要权限” 会被系统拒绝;而且必须在鸿蒙的AGC后台进行同步申请该权限,因为该权限属于受限权限;而且!!!如果是使用的鸿蒙自家的隐私托管服务,必须要在隐私托管服务中同步该权限的声明
  • 解决:使用uts插件配置权限;然后在AGC后台同步申请该受限权限通过率直接 100%。
    // #ifdef APP-HARMONY  
    import "@/uni_modules/harmony-permissions"  
    // #endif 
 {  
  "module": {  
    "name": "uni_modules__harmony_permissions",  
    "type": "har",  
    "deviceTypes": [  
      "default",  
      "tablet",  
      "2in1"  
    ],  
    "requestPermissions": [  
      {  
        "name": "ohos.permission.INTERNET"  
      },{  
        "name": "ohos.permission.WRITE_MEDIA",  
        "reason": "$string:media_desc",  
        "usedScene": {"when": "inuse"}  
      },{  
        "name": "ohos.permission.READ_MEDIA",  
        "reason": "$string:media_desc",  
        "usedScene": {"when": "inuse"}  
      }  
    ]  
  }  
}

服务卡片适配:桌面卡片让古诗 “触手可及”
鸿蒙的桌面卡片是个大亮点,我想做个 “每日一句” 卡片,用户不用打开 App,桌面就能看到古诗。

  • 坑点:UniAPP 打包鸿蒙应用时,卡片的尺寸适配很麻烦,比如 3x2、2x2 的卡片布局会错乱,而且卡片数据刷新需要和主 App 同步;
  • 解决:用 UniAPP 提供的uni-app x编译模式(专门优化鸿蒙适配),卡片布局用flex弹性盒,固定宽高比;数据同步用uniCloud实时数据库,主 App 更新 “每日一句” 后,卡片自动刷新,不用用户手动操作 —— 现在不少用户说 “每天解锁手机先看一眼古诗,感觉很治愈”。

音频播放:后台播放老是 “断档”
朗读广场的核心是 “听别人读古诗”,但一开始用户反馈 “退到后台就停了”,体验很差。

  • 坑点:鸿蒙对后台音频播放有严格限制,普通的audio组件在后台会被系统暂停;
  • 解决:集成了鸿蒙的AVPlayer SDK(UniAPP 可以直接调用鸿蒙原生 SDK),在manifest.json里配置 “后台音频权限”,同时用 UniAPP 的onBackground生命周期监听,退后台时自动切换到鸿蒙原生播放器,现在后台播放能稳定运行,用户还能边听古诗边刷微信~

四、📦功能落地:每个模块都是 “心头好”📦

结合古诗的核心需求,几个功能模块都是 “边踩坑边优化” 出来的,挑 3 个重点说:

  1. 背一背:用 “艾宾浩斯曲线” 让背诵不费脑
    一开始只是简单做了 “列表 + 背诵打卡”,但用户反馈 “背了就忘”。后来用 UniAPP 的storage存储用户背诵记录,结合艾宾浩斯遗忘曲线,在对应时间点推送提醒(比如第 1 天、第 3 天、第 7 天),还加了 “遮字默写” 功能 —— 点击诗句能隐藏关键字,像考试一样检验效果。

    • 小技巧:用 UniAPP 的uni.createPushMessage调用鸿蒙的通知权限,提醒文案特意用古诗意境,比如 “床前明月光,今天该复习啦~”,比干巴巴的 “请背诵” 更讨喜。
      <view class="poem-content">  
      <block v-if="poemStore.showFullContent">  
        <text v-for="(line, idx) in poemStore.currentPoem.content" :key="idx" class="full-line">{{ line }}</text>  
      </block>  
      <block v-else>  
        <text v-for="(line, idx) in poemStore.currentPoem.content" :key="idx" class="hide-line">□□□□□□□</text>  
      </block>  
      </view>  
      <button class="toggle-btn" @click="poemStore.toggleFullContent">  
      {{ poemStore.showFullContent ? '切换到背诵模式' : '查看完整诗句' }}  
      </button>
      <script setup lang="ts">  
      import { usePoemStore } from '@/stores/poemStore';  
      import { useRouter } from 'vue-router';  
      const poemStore = usePoemStore();  
      const router = useRouter();  
      const goBack = () => {  
      router.back();  
      };  
      const handleCollect = () => {  
      if (poemStore.currentPoem) {  
      poemStore.toggleCollect(poemStore.currentPoem.id);  
      }  
      };  
      </script>
  2. 诗词 / 成语接龙:云开发让匹配更流畅
    接龙功能需要实时匹配用户输入的诗句 / 成语,还要校验是否正确、有没有重复。一开始用本地数据库,结果数据量太大(收录了 2 万 + 古诗、1 万 + 成语),App 启动变慢。

    export interface Poem {  
    id: number;  
    title: string; // 诗名  
    author: string; // 作者(含朝代)  
    content: string[]; // 诗句数组  
    category: string; // 分类(如山水、边塞)  
    isCollected: boolean; // 是否收藏  
    }
    • 优化:改用uniCloud云开发,把诗词库、成语库存到云端,用云函数做匹配校验(比如输入 “举头望明月”,云函数自动查询以 “月” 开头的诗句),App 端只负责展示和输入,启动速度从 3 秒降到 1 秒,接龙延迟也几乎感知不到。
  3. 主题颜色:贴合古风的 “颜值小心思”
    考虑到用户可能在不同场景使用(比如晚上背古诗要护眼),做了 3 种古风主题:黛青(默认)、朱砂(暖调)、月白(护眼)。

    • 实现:用 UniAPP 的vuex管理主题状态,结合鸿蒙的 “系统深色模式”,用户切换系统主题时,App 自动适配对应的古风配色 —— 比如系统开深色模式,App 自动切月白主题,字体加粗,保护视力。
      import { defineStore } from 'pinia';  
      import { Poem } from '@/types/poem';  
      const mockPoems: Poem[] = [  
      {  
      id: 1,  
      title: '望庐山瀑布',  
      author: '唐·李白',  
      content: ['日照香炉生紫烟', '遥看瀑布挂前川', '飞流直下三千尺', '疑是银河落九天'],  
      category: '山水',  
      isCollected: false  
      },  
      {  
      id: 2,  
      title: '静夜思',  
      author: '唐·李白',  
      content: ['床前明月光', '疑是地上霜', '举头望明月', '低头思故乡'],  
      category: '思乡',  
      isCollected: false  
      },  
      {  
      id: 3,  
      title: '春晓',  
      author: '唐·孟浩然',  
      content: ['春眠不觉晓', '处处闻啼鸟', '夜来风雨声', '花落知多少'],  
      category: '春景',  
      isCollected: false  
      }  
      ];  
      export const usePoemStore = defineStore('poem', {  
      state: () => ({  
      poems: mockPoems as Poem[], // 古诗列表  
      currentPoem: null as Poem | null, // 当前选中古诗  
      showFullContent: false // 详情页是否显示完整诗句(背诵模式控制)  
      }),  
      actions: {  
      // 选中古诗  
      selectPoem(poemId: number) {  
      this.currentPoem = this.poems.find(poem => poem.id === poemId) || null;  
      this.showFullContent = false; // 进入背诵模式,默认隐藏完整诗句  
      },  
      // 切换收藏状态  
      toggleCollect(poemId: number) {  
      const poem = this.poems.find(poem => poem.id === poemId);  
      if (poem) poem.isCollected = !poem.isCollected;  
      },  
      // 切换完整诗句显示(背诵/查看模式)  
      toggleFullContent() {  
      this.showFullContent = !this.showFullContent;  
      }  
      }  
      });

五、✅ 后续功能想法 ✅

  • “想要更多主题颜色”→ 新增 “竹绿”“藤黄” 2 种配色;
  • “朗读广场想给作品点赞”→ 加了点赞功能,用uniCloud存储互动数据;
  • “背古诗想有奖励”→ 做了 “背诵勋章”,集齐 5 个勋章能解锁古诗插画壁纸。

虽然还未成功上架市场,但在开发中寻找用户体验过程中,最让我感动的是一条评论:“我家娃以前不爱背古诗,现在每天打卡接龙,还会给同学分享自己的朗读作品,谢谢开发者让传统文化变有趣~” 这种时候,觉得熬夜改 bug、反复适配都值了!

六、🌟星光不负:技术与文化的双向奔赴🌟

从一开始的 “想做个古诗工具”,到现在的 “让自己更了解古诗并爱上它”,用 UniAPP 开发鸿蒙 App 的这 3 个月,不仅让我摸清了跨平台开发的套路,更懂了 “技术是为需求服务”—— 不是堆功能,而是让应用真正有用起来、用得爽。
回头看,选择 UniAPP 是最正确的决定:它让我不用纠结原生语法,能把更多精力放在 “怎么让古诗更有趣” 上;而鸿蒙的开放能力(云开发、云调试、HarmonyOS SDK、云测试)等,又让 App 的体验更上一层楼。就像古诗里说的 “行则将至”,技术之路没有捷径,但只要朝着目标一步步走,星光总会照亮前路。
接下来,我还想加 AR 功能(用鸿蒙的 AR SDK,扫描实景生成古诗意境)、古诗合唱(朗读广场支持多人合拍),让传统文化通过技术 “活” 起来、“火” 起来。如果你也在做 UniAPP + 鸿蒙开发,或者对古诗 App 有更多想法,欢迎一起交流~ 毕竟,码向未来的路上,有人同行更精彩!

收起阅读 »

【鸿蒙征文】鸿蒙开发踩坑日记:一位前端开发者的血泪成长史

鸿蒙next 鸿蒙征文

日记一:环境搭建——“Hello, World!” 前的下马威

坑点: 模拟器启动失败,报错 HAXM is not installedVT-x is not available

踩坑过程:

信心满满地安装完 DevEco Studio,创建第一个 Hello World 项目,点击运行模拟器。结果,控制台报出一串红色错误,模拟器屏幕一片漆黑。心里“咯噔”一下,难道第一步就卡住了?

排查与解决:

  1. 检查BIOS: 重启电脑,狂按 F2/Del 键进入 BIOS 设置。找到 Intel Virtualization Technology(或 AMD 的 SVM Mode)选项,确保其状态为 Enabled。这是最根本的原因。
  2. 开启Windows功能: 在 Windows 搜索栏输入“启用或关闭 Windows 功能”,确保 Hyper-VWindows 虚拟机监控平台 已被勾选。完成后需要重启电脑。
  3. 选择正确的模拟器: 在 DevEco Studio 的设备管理器中,确保下载的是 API 9或更高版本的模拟器。低版本 API 的模拟器可能存在兼容性问题。

心得: 鸿蒙开发环境的搭建,第一步就是和硬件虚拟化打交道。这提醒我,移动开发生态已与 Web 开发那种“开箱即用”的体验截然不同。

日记二:UTS 类型系统 —— “像”TypeScript,但不是 TypeScript

坑点一:Map.forEach的消失

代码:

let myMap = new Map<string, number>();  
myMap.set('apple', 5);  
// 在 iOS 上运行良好,在鸿蒙上报错!  
myMap.forEach((value, key) => {  
  console.log(key, value);  
});

错误: TypeError: undefined is not callable

解决方案:

// 方案1:使用 for...of 循环  
for (let [key, value] of myMap.entries()) {  
  console.log(key, value);  
}  

// 方案2:更保险的做法,先判断平台或方法是否存在  
if (myMap.forEach) {  
  myMap.forEach((value, key) => { /* ... */ });  
} else {  
  for (let [key, value] of myMap.entries()) { /* ... */ }  
}

坑点二:隐式类型转换的终结

代码:

let data: string | null = getDataFromAPI();  
// Web 中常见的真值判断,在 UTS 中报错!  
if (data) {   
  processData(data);  
}

错误: The condition expression must be of boolean type.

解决方案:

// 必须进行显式的布尔判断  
if (data != null) {  
  processData(data);  
}  

// 或者判断字符串长度  
if (data?.length > 0) {  
  processData(data;  
}

心得: UTS 的强类型特性像一位严格的老师,逼着我写出更严谨、更少歧义的代码。虽然初期不适应,但从代码质量角度看,是件好事。

日记三:页面布局与样式 —— CSS 的“阉割版”体验

坑点一:100vh的陷阱与滚动失效

代码:

<template>  
  <view class="container">  
    <scroll-view scroll-y class="scroll-area">  
      <!-- 长内容 -->  
    </scroll-view>  
    <view class="fixed-bottom">我是一个底部固定栏</view>  
  </view>  
</template>  

<style>  
.container {  
  height: 100vh; /* 鸿蒙不支持 vh! */  
}  
.scroll-area {  
  height: 100%; /* 继承自一个高度无效的容器,滚动失效 */  
}  
</style>

现象: 页面无法滚动,scroll-view区域高度为 0。

解决方案:

<template>  
  <!-- 关键:让 scroll-view 作为根节点或充满整个页面 -->  
  <scroll-view scroll-y class="page-root">  
    <!-- 所有内容,包括原本想固定的元素,都放在里面 -->  
    <view class="content">可滚动内容</view>  
    <view class="bottom-placeholder"></view>  
    <view class="fixed-bottom">利用 absolute 或 fixed 定位模拟固定</view>  
  </scroll-view>  
</template>  

<style>  
.page-root {  
  width: 100%;  
  height: 100%; /* 使用 100% 而不是 100vh */  
  position: relative; /* 为固定定位元素提供参考 */  
}  
.fixed-bottom {  
  position: absolute;  
  bottom: 0;  
  width: 100%;  
}  
.bottom-placeholder {  
  height: 100rpx; /* 为固定底部留出占位空间,避免内容被遮挡 */  
}

坑点二:Flex 布局的微妙差异

在 Web 中,display: flex的容器默认是 flex-direction: row。在鸿蒙的 text组件嵌套 span时,我发现 Flex 布局的表现有时与 Web 有细微差别,导致文字排版错乱。

解决方案: 尽量显式地写明 flex-direction,避免依赖默认值。对于复杂布局,多使用 align-itemsjustify-content进行微调。

心得: 必须彻底抛弃 Web 的“文档流”思维,拥抱原生应用的“盒子模型”和明确的滚动容器概念。布局要更“笨拙”但更精确。

日记四:数据库操作 —— 最诡异的“数据隐身术”

坑点:查询结果对象“看起来有数据,却取不出来”

代码:

// 执行查询  
const result = db.query(‘SELECT name, count FROM ingredients’);  
const rows = result.maps;  
console.log(‘结果行数:’, rows.length); // 输出: 结果行数: 5  
console.log(‘第一行:’, JSON.stringify(rows[0])); // 输出: 第一行: {}  

// 以下代码在 iOS 上正常,在鸿蒙上为 undefined  
const name = rows[0][‘name’];

现象: 数据“幽灵”,日志显示有数据,但代码无法访问。

根源: 鸿蒙平台返回的 rows不是普通对象数组,而是 Map<string, any>[] JSON.stringify对 Map 序列化会得到空对象 {}

解决方案:

// 封装一个强大的兼容性取值函数  
function getRowValue(row: any, key: string, defaultValue: any = null): any {  
  if (row == null) return defaultValue;  

  // 方法1:判断是否是 Map  
  if (row instanceof Map) {  
    return row.get(key) ?? defaultValue;  
  }  
  // 方法2:判断是否拥有 get 方法 (更通用)  
  else if (typeof row.get === ‘function’) {  
    return row.get(key) ?? defaultValue;  
  }  
  // 方法3:默认为普通对象  
  else {  
    return row[key] ?? defaultValue;  
  }  
}  

// 使用  
const name = getRowValue(rows[0], ‘name’);  
const count = getRowValue(rows[0], ‘count’, 0);

心得: 跨平台开发中,“数据序列化/反序列化”是头号隐形杀手。不能相信表面现象,必须对关键数据的实际类型进行运行时判断。

日记五:网络请求与生命周期 —— 异步世界的时序陷阱

坑点:onLoad中发起请求,在 onReady中访问数据却为 null

代码:

export default {  
  data() {  
    return {  
      recipeData: null  
    }  
  },  
  onLoad() {  
    uni.request({  
      url: ‘/api/recipe’,  
      success: (res) => {  
        this.recipeData = res.data; // 异步赋值  
      }  
    });  
  },  
  onReady() {  
    // 企图使用数据渲染视图,但 recipeData 很可能还是 null!  
    this.renderChart(this.recipeData); // 报错!  
  }  
}

解决方案:

  1. 使用状态管理: 引入 Pinia,在请求成功的回调中提交 mutation 改变状态,组件通过 computed 属性响应式地获取数据。

  2. 条件渲染: 在模板中根据数据是否存在进行判断。

    <template>  
     <view>  
       <chart v-if=“recipeData” :data=“recipeData”></chart>  
       <loading v-else>加载中…</loading>  
     </view>  
    </template>  
  3. 使用 Promise/async-await: 确保在数据准备好后再执行后续操作。

    async onLoad() {  
     try {  
       this.recipeData = await this.fetchRecipeData();  
       // 数据获取成功后,再执行需要数据的操作  
       this.$nextTick(() => {  
         this.renderChart(this.recipeData);  
       });  
     } catch (error) {  
       console.error(‘数据加载失败:’, error);  
     }  
    }  

心得: 生命周期钩子和异步操作的配合,是前端开发永恒的课题。在鸿蒙这种更接近原生的环境中,对时序的控制要求更为严格。

总结:我的踩坑生存法则

  1. 假设一切都有平台差异: 对任何 API、组件、样式属性,都抱有一丝怀疑,优先查阅鸿蒙专属文档。
  2. 日志是最好(有时是唯一)的调试工具: 不仅要打印值,还要打印类型(typeof, instanceof)。
  3. 封装兼容层: 将平台差异(如数据库取值、网络库)封装成统一的工具函数,是长期项目的必备架构。
  4. 小步快跑,频繁测试: 每实现一个小功能,就在鸿蒙模拟器或真机上跑一遍,避免错误累积。
  5. 拥抱社区: uni-app 的官方论坛、钉钉群和 GitHub issues 是解决问题的宝库,你踩的坑,大概率已经有人踩过并分享了解决方案。

踩坑虽苦,但每解决一个坑,对鸿蒙平台和跨平台开发的理解就加深一层。现在回头看,这些坑都成了我宝贵的经验财富。希望这份日记,能为你照亮前行的路,助你少走弯路!

继续阅读 »

日记一:环境搭建——“Hello, World!” 前的下马威

坑点: 模拟器启动失败,报错 HAXM is not installedVT-x is not available

踩坑过程:

信心满满地安装完 DevEco Studio,创建第一个 Hello World 项目,点击运行模拟器。结果,控制台报出一串红色错误,模拟器屏幕一片漆黑。心里“咯噔”一下,难道第一步就卡住了?

排查与解决:

  1. 检查BIOS: 重启电脑,狂按 F2/Del 键进入 BIOS 设置。找到 Intel Virtualization Technology(或 AMD 的 SVM Mode)选项,确保其状态为 Enabled。这是最根本的原因。
  2. 开启Windows功能: 在 Windows 搜索栏输入“启用或关闭 Windows 功能”,确保 Hyper-VWindows 虚拟机监控平台 已被勾选。完成后需要重启电脑。
  3. 选择正确的模拟器: 在 DevEco Studio 的设备管理器中,确保下载的是 API 9或更高版本的模拟器。低版本 API 的模拟器可能存在兼容性问题。

心得: 鸿蒙开发环境的搭建,第一步就是和硬件虚拟化打交道。这提醒我,移动开发生态已与 Web 开发那种“开箱即用”的体验截然不同。

日记二:UTS 类型系统 —— “像”TypeScript,但不是 TypeScript

坑点一:Map.forEach的消失

代码:

let myMap = new Map<string, number>();  
myMap.set('apple', 5);  
// 在 iOS 上运行良好,在鸿蒙上报错!  
myMap.forEach((value, key) => {  
  console.log(key, value);  
});

错误: TypeError: undefined is not callable

解决方案:

// 方案1:使用 for...of 循环  
for (let [key, value] of myMap.entries()) {  
  console.log(key, value);  
}  

// 方案2:更保险的做法,先判断平台或方法是否存在  
if (myMap.forEach) {  
  myMap.forEach((value, key) => { /* ... */ });  
} else {  
  for (let [key, value] of myMap.entries()) { /* ... */ }  
}

坑点二:隐式类型转换的终结

代码:

let data: string | null = getDataFromAPI();  
// Web 中常见的真值判断,在 UTS 中报错!  
if (data) {   
  processData(data);  
}

错误: The condition expression must be of boolean type.

解决方案:

// 必须进行显式的布尔判断  
if (data != null) {  
  processData(data);  
}  

// 或者判断字符串长度  
if (data?.length > 0) {  
  processData(data;  
}

心得: UTS 的强类型特性像一位严格的老师,逼着我写出更严谨、更少歧义的代码。虽然初期不适应,但从代码质量角度看,是件好事。

日记三:页面布局与样式 —— CSS 的“阉割版”体验

坑点一:100vh的陷阱与滚动失效

代码:

<template>  
  <view class="container">  
    <scroll-view scroll-y class="scroll-area">  
      <!-- 长内容 -->  
    </scroll-view>  
    <view class="fixed-bottom">我是一个底部固定栏</view>  
  </view>  
</template>  

<style>  
.container {  
  height: 100vh; /* 鸿蒙不支持 vh! */  
}  
.scroll-area {  
  height: 100%; /* 继承自一个高度无效的容器,滚动失效 */  
}  
</style>

现象: 页面无法滚动,scroll-view区域高度为 0。

解决方案:

<template>  
  <!-- 关键:让 scroll-view 作为根节点或充满整个页面 -->  
  <scroll-view scroll-y class="page-root">  
    <!-- 所有内容,包括原本想固定的元素,都放在里面 -->  
    <view class="content">可滚动内容</view>  
    <view class="bottom-placeholder"></view>  
    <view class="fixed-bottom">利用 absolute 或 fixed 定位模拟固定</view>  
  </scroll-view>  
</template>  

<style>  
.page-root {  
  width: 100%;  
  height: 100%; /* 使用 100% 而不是 100vh */  
  position: relative; /* 为固定定位元素提供参考 */  
}  
.fixed-bottom {  
  position: absolute;  
  bottom: 0;  
  width: 100%;  
}  
.bottom-placeholder {  
  height: 100rpx; /* 为固定底部留出占位空间,避免内容被遮挡 */  
}

坑点二:Flex 布局的微妙差异

在 Web 中,display: flex的容器默认是 flex-direction: row。在鸿蒙的 text组件嵌套 span时,我发现 Flex 布局的表现有时与 Web 有细微差别,导致文字排版错乱。

解决方案: 尽量显式地写明 flex-direction,避免依赖默认值。对于复杂布局,多使用 align-itemsjustify-content进行微调。

心得: 必须彻底抛弃 Web 的“文档流”思维,拥抱原生应用的“盒子模型”和明确的滚动容器概念。布局要更“笨拙”但更精确。

日记四:数据库操作 —— 最诡异的“数据隐身术”

坑点:查询结果对象“看起来有数据,却取不出来”

代码:

// 执行查询  
const result = db.query(‘SELECT name, count FROM ingredients’);  
const rows = result.maps;  
console.log(‘结果行数:’, rows.length); // 输出: 结果行数: 5  
console.log(‘第一行:’, JSON.stringify(rows[0])); // 输出: 第一行: {}  

// 以下代码在 iOS 上正常,在鸿蒙上为 undefined  
const name = rows[0][‘name’];

现象: 数据“幽灵”,日志显示有数据,但代码无法访问。

根源: 鸿蒙平台返回的 rows不是普通对象数组,而是 Map<string, any>[] JSON.stringify对 Map 序列化会得到空对象 {}

解决方案:

// 封装一个强大的兼容性取值函数  
function getRowValue(row: any, key: string, defaultValue: any = null): any {  
  if (row == null) return defaultValue;  

  // 方法1:判断是否是 Map  
  if (row instanceof Map) {  
    return row.get(key) ?? defaultValue;  
  }  
  // 方法2:判断是否拥有 get 方法 (更通用)  
  else if (typeof row.get === ‘function’) {  
    return row.get(key) ?? defaultValue;  
  }  
  // 方法3:默认为普通对象  
  else {  
    return row[key] ?? defaultValue;  
  }  
}  

// 使用  
const name = getRowValue(rows[0], ‘name’);  
const count = getRowValue(rows[0], ‘count’, 0);

心得: 跨平台开发中,“数据序列化/反序列化”是头号隐形杀手。不能相信表面现象,必须对关键数据的实际类型进行运行时判断。

日记五:网络请求与生命周期 —— 异步世界的时序陷阱

坑点:onLoad中发起请求,在 onReady中访问数据却为 null

代码:

export default {  
  data() {  
    return {  
      recipeData: null  
    }  
  },  
  onLoad() {  
    uni.request({  
      url: ‘/api/recipe’,  
      success: (res) => {  
        this.recipeData = res.data; // 异步赋值  
      }  
    });  
  },  
  onReady() {  
    // 企图使用数据渲染视图,但 recipeData 很可能还是 null!  
    this.renderChart(this.recipeData); // 报错!  
  }  
}

解决方案:

  1. 使用状态管理: 引入 Pinia,在请求成功的回调中提交 mutation 改变状态,组件通过 computed 属性响应式地获取数据。

  2. 条件渲染: 在模板中根据数据是否存在进行判断。

    <template>  
     <view>  
       <chart v-if=“recipeData” :data=“recipeData”></chart>  
       <loading v-else>加载中…</loading>  
     </view>  
    </template>  
  3. 使用 Promise/async-await: 确保在数据准备好后再执行后续操作。

    async onLoad() {  
     try {  
       this.recipeData = await this.fetchRecipeData();  
       // 数据获取成功后,再执行需要数据的操作  
       this.$nextTick(() => {  
         this.renderChart(this.recipeData);  
       });  
     } catch (error) {  
       console.error(‘数据加载失败:’, error);  
     }  
    }  

心得: 生命周期钩子和异步操作的配合,是前端开发永恒的课题。在鸿蒙这种更接近原生的环境中,对时序的控制要求更为严格。

总结:我的踩坑生存法则

  1. 假设一切都有平台差异: 对任何 API、组件、样式属性,都抱有一丝怀疑,优先查阅鸿蒙专属文档。
  2. 日志是最好(有时是唯一)的调试工具: 不仅要打印值,还要打印类型(typeof, instanceof)。
  3. 封装兼容层: 将平台差异(如数据库取值、网络库)封装成统一的工具函数,是长期项目的必备架构。
  4. 小步快跑,频繁测试: 每实现一个小功能,就在鸿蒙模拟器或真机上跑一遍,避免错误累积。
  5. 拥抱社区: uni-app 的官方论坛、钉钉群和 GitHub issues 是解决问题的宝库,你踩的坑,大概率已经有人踩过并分享了解决方案。

踩坑虽苦,但每解决一个坑,对鸿蒙平台和跨平台开发的理解就加深一层。现在回头看,这些坑都成了我宝贵的经验财富。希望这份日记,能为你照亮前行的路,助你少走弯路!

收起阅读 »

【鸿蒙征文】uni-app 打通鸿蒙踩坑日记:从迷茫到上架的完整心路历程

鸿蒙next 鸿蒙征文

uni-app 打通鸿蒙踩坑日记:从迷茫到上架的完整心路历程

缘起:为什么选择鸿蒙

作为一名前端开发者,我一直在关注各种新兴的技术生态。当华为宣布鸿蒙系统将独立发展时,我就意识到这可能是下一个重要的技术风口。但真正促使我动手的,还是实际的需求痛点。

我们团队有一个成熟的 uni-app 项目,已经在 iOS 和 Android 上稳定运行了两年。但随着华为设备市场份额的不断扩大,越来越多的用户反馈希望有鸿蒙版本。看着应用商店里竞品陆续上线鸿蒙版本,我决定亲自趟一趟这趟"浑水"。

从今年 3 月开始,我花了近三个月时间,完成了从技术调研到实际上架的完整过程。今天就来分享这段充满挑战又收获满满的经历。

技术选型:为什么坚持 uni-app

在开始之前,团队内部有过激烈的讨论:是重新开发原生鸿蒙应用,还是基于现有 uni-app 项目进行适配?

重新开发原生鸿蒙的优势

  • 性能最优,体验最佳
  • 可以充分利用鸿蒙的特有能力
  • 技术栈更纯粹

但最终我们还是选择了 uni-app 适配,原因很现实:

  1. 开发成本:团队熟悉 Vue 技术栈,重新学习 ArkTS 成本太高
  2. 维护成本:三套代码意味着三倍的维护工作量
  3. 迭代速度:业务需求变化快,需要快速响应
  4. DCloud 的承诺:官方明确表示会持续加强鸿蒙支持

事后证明,这个选择是正确的,但过程远比想象中曲折。

环境搭建:第一个坑就栽了跟头

按照官方文档,我信心满满地开始环境搭建。HBuilderX、DevEco Studio、Node.js,一切看起来都很简单。

第一个坑:模拟器选择

文档说"下载 API 19 模拟器即可",但我下载后始终无法启动。各种错误提示看得我头皮发麻:

模拟器启动失败:硬件加速未开启  
VT-x 不可用,请检查 BIOS 设置

我花了整整两天时间:

  • 重启电脑进入 BIOS 开启虚拟化
  • 在 Windows 功能中启用 Hyper-V
  • 更新显卡驱动
  • 重装 DevEco Studio

最后发现是杀毒软件冲突。关闭某数字卫士后,模拟器终于正常启动了。

经验教训:环境问题不要死磕,及时换思路。后来我直接使用真机调试,效率反而更高。

项目迁移:看似顺利的开始

将现有 uni-app 项目迁移到鸿蒙,最初进展出乎意料的顺利。

基础页面适配

大部分 Vue 页面无需修改即可运行,uni-app 的跨端能力确实令人印象深刻:

<template>  
  <view class="container">  
    <text class="title">{{ title }}</text>  
    <button @click="handleClick">点击我</button>  
  </view>  
</template>  

<script>  
export default {  
  data() {  
    return {  
      title: 'Hello HarmonyOS'  
    }  
  },  
  methods: {  
    handleClick() {  
      uni.showToast({  
        title: '点击成功'  
      })  
    }  
  }  
}  
</script>  

<style>  
.container {  
  padding: 20px;  
}  
.title {  
  font-size: 18px;  
  color: #333;  
}  
</style>

路由配置

pages.json的配置也完全兼容:

{  
  "pages": [  
    {  
      "path": "pages/index/index",  
      "style": {  
        "navigationBarTitleText": "首页"  
      }  
    },  
    {  
      "path": "pages/detail/detail",   
      "style": {  
        "navigationBarTitleText": "详情页"  
      }  
    }  
  ]  
}

静态资源

static目录下的图片、字体等资源都能正常加载。前 80% 的工作在两天内就完成了,我甚至觉得鸿蒙开发不过如此。

但接下来的 20%,花了我 80% 的时间。

深水区:平台特定适配

导航栏差异

第一个平台差异出现在导航栏。在 iOS 和 Android 上正常的导航栏,在鸿蒙上出现了错位。

问题现象

  • 标题位置偏移
  • 返回按钮点击区域异常
  • 状态栏颜色不匹配

解决方案

pages.json中为鸿蒙平台单独配置:

{  
  "pages": [  
    {  
      "path": "pages/index/index",  
      "style": {  
        "navigationBarTitleText": "首页",  
        "app-plus": {  
          "titleNView": {  
            // iOS/Android 配置  
          }  
        },  
        "harmony": {  
          "navigationBarBackgroundColor": "#FFFFFF",  
          "navigationBarTextStyle": "black",  
          "navigationBarTitleText": "首页",  
          "navigationStyle": "default"  
        }  
      }  
    }  
  ]  
}

组件兼容性问题

更大的挑战来自组件兼容性。我们项目中使用了大量第三方组件,有些在鸿蒙上表现异常。

案例:图片裁剪组件

在 iOS/Android 上正常的图片裁剪组件,在鸿蒙上出现以下问题:

  • 触摸事件响应错乱
  • 裁剪框位置计算错误
  • 性能卡顿明显

排查过程

  1. 首先怀疑是触摸事件机制差异
  2. 尝试修改事件处理逻辑,效果有限
  3. 深入组件源码,发现使用了 DOM API
  4. 鸿蒙不支持部分 DOM API

最终方案

寻找替代组件,选择明确支持鸿蒙的图片裁剪库。这个过程让我意识到:不是所有 web 生态都能无缝迁移到鸿蒙

API 兼容性

uni-app 的 API 在鸿蒙上的支持程度不一:

完全兼容的

  • uni.showToast
  • uni.request
  • uni.getStorage
  • uni.chooseImage

需要适配的

  • uni.getSystemInfo返回的信息结构不同
  • uni.onKeyboardHeightChange在鸿蒙上触发机制不同
  • 部分设备 API 需要鸿蒙特定实现

不支持的

  • 某些平台特定的扩展 API

我们建立了兼容性检查清单,对每个 API 进行测试:

// 兼容性封装示例  
export const getSafeArea = () => {  
  return new Promise((resolve, reject) => {  
    uni.getSystemInfo({  
      success: (res) => {  
        // 鸿蒙平台特殊处理  
        if (res.platform === 'harmony') {  
          resolve({  
            top: res.statusBarHeight || 0,  
            bottom: 0,  
            left: 0,  
            right: 0  
          })  
        } else {  
          resolve(res.safeArea)  
        }  
      },  
      fail: reject  
    })  
  })  
}

性能优化:从卡顿到流畅

首次在真机上运行应用时,性能问题让我震惊:列表滚动卡顿、页面切换白屏、内存占用过高。

列表性能优化

问题:商品列表页有 1000+ 项目,滚动时严重卡顿。

分析

  • 鸿蒙的列表渲染机制与 web 不同
  • 大量 DOM 节点导致内存压力
  • 图片加载没有做优化

解决方案

  1. 虚拟列表
<template>  
  <view class="list-container">  
    <unicloud-db   
      ref="udb"  
      collection="goods"  
      where="category_id == categoryId"  
      orderby="create_time desc"  
      :page-size="20"  
      @load="onListLoad"  
      v-slot:default="{data, loading, error}">  

      <virtual-list   
        :data="data"  
        :item-size="100"  
        key-field="_id">  
        <template v-slot:default="{item}">  
          <goods-item :item="item" />  
        </template>  
      </virtual-list>  

    </unicloud-db>  
  </view>  
</template>
  1. 图片懒加载
<image   
  :src="item.image"   
  mode="aspectFill"  
  lazy-load  
  :fade-show="false"  
  @load="onImageLoad"  
  @error="onImageError">  
</image>
  1. 内存管理
// 监听页面生命周期  
onPageScroll(e) {  
  // 可视区域外的图片取消加载  
  this.checkVisibleImages()  
},  

onHide() {  
  // 页面隐藏时释放大资源  
  this.clearCache()  
}

启动速度优化

问题:应用冷启动需要 3-5 秒,体验较差。

优化措施

  1. 代码分包
// pages.json  
{  
  "subPackages": [  
    {  
      "root": "pagesA",  
      "pages": [  
        "page1/page1",  
        "page2/page2"  
      ]  
    },  
    {  
      "root": "pagesB",   
      "pages": [  
        "page3/page3",  
        "page4/page4"  
      ]  
    }  
  ]  
}
  1. 预加载关键资源
// app.vue  
onLaunch() {  
  // 预加载首屏必要数据  
  this.preloadEssentialData()  

  // 异步加载非关键资源  
  setTimeout(() => {  
    this.preloadSecondaryResources()  
  }, 1000)  
}
  1. 减少同步操作
// 优化前:同步阻塞  
const userInfo = uni.getStorageSync('userInfo')  
const settings = uni.getStorageSync('settings')  

// 优化后:异步并行  
Promise.all([  
  this.getStorage('userInfo'),  
  this.getStorage('settings')  
]).then(([userInfo, settings]) => {  
  // 处理数据  
})

经过优化后,启动时间缩短到 1-2 秒,列表滚动帧率稳定在 60fps。

打包发布:证书的"坑爹"之旅

如果说代码适配是技术挑战,那证书和打包就是流程挑战。

证书配置的坑

第一个坑:证书类型混淆

我一开始把调试证书当成发布证书使用,结果打包时各种错误:

错误: 证书类型不匹配  
错误: 签名验证失败

解决方案

  • 调试证书:用于开发阶段真机调试
  • 发布证书:用于应用商店上架
  • 两者不能混用

第二个坑:证书有效期

发布证书有一年有效期,但测试证书只有三个月。我第一次提交审核时,证书差点过期。

经验:设置证书到期提醒,提前一个月更新。

打包流程优化

手动打包效率低下,我建立了自动化流程:

// package.json 脚本  
{  
  "scripts": {  
    "build:harmony:debug": "uni build --platform harmony --mode development",  
    "build:harmony:release": "uni build --platform harmony --mode production",  
    "pack:harmony": "node scripts/pack-harmony.js",  
    "deploy:harmony": "npm run build:harmony:release && npm run pack:harmony"  
  }  
}  
// scripts/pack-harmony.js  
const { execSync } = require('child_process')  
const fs = require('fs')  
const path = require('path')  

console.log('🚀 开始鸿蒙应用打包...')  

// 检查构建产物  
const buildPath = path.join(__dirname, '../dist/build/harmony')  
if (!fs.existsSync(buildPath)) {  
  console.error('❌ 构建产物不存在,请先执行构建')  
  process.exit(1)  
}  

// 执行鸿蒙特定打包逻辑  
try {  
  execSync('node scripts/harmony-specific-pack.js', { stdio: 'inherit' })  
  console.log('✅ 鸿蒙应用打包完成')  
} catch (error) {  
  console.error('❌ 打包失败:', error)  
  process.exit(1)  
}

上架审核:意料之外的拒绝

第一次提交审核时,我信心满满,觉得经过充分测试的应用肯定能一次通过。结果被打脸了。

审核拒绝原因

  1. 隐私政策不完整缺少数据存储说明未说明第三方 SDK 数据收集
  2. 权限说明不清晰相机权限用途描述过于简单位置权限必要性未充分说明
  3. 应用截图不规范截图包含状态栏时间"17:30"部分截图尺寸不一致

解决方案

隐私政策完善

## 数据存储说明  
本应用使用本地存储保存用户偏好设置,使用华为云存储备份用户数据。所有数据均加密存储。  

## 第三方 SDK 说明  
- 华为账号 SDK:用于用户登录认证  
- 华为推送 SDK:用于消息推送  
- 微信分享 SDK:用于内容分享

权限说明重写

## 相机权限  
用途:用于扫描商品条形码、拍摄商品照片  
必要性:核心功能需要,无法替代  
数据处理:图片仅在本地处理,不上传服务器  

## 位置权限    
用途:用于推荐附近门店、配送地址定位  
必要性:增值功能需要,非核心功能  
数据处理:位置信息加密传输到服务器

截图规范

  • 使用模拟器统一截图
  • 隐藏状态栏敏感信息
  • 确保所有截图尺寸一致
  • 展示核心功能流程

第二次提交后,顺利通过审核。

监控与反馈:上架只是开始

应用上架后,真正的挑战才开始。用户反馈和线上监控发现了我们测试中未发现的问题。

线上问题排查

案例:特定机型闪退

有用户反馈在华为 Mate 40 上频繁闪退,但我们测试机上正常。

排查过程

  1. 查看 AGC 崩溃日志
  2. 发现内存溢出错误
  3. 定位到图片加载组件
  4. 特定机型内存管理更严格

解决方案

// 图片加载优化  
loadImage(url) {  
  return new Promise((resolve, reject) => {  
    const img = new Image()  

    // 限制图片尺寸  
    if (this.isLowMemoryDevice()) {  
      url = this.addImageSizeParams(url, '800x600')  
    }  

    img.onload = () => {  
      // 及时释放引用  
      img.onload = null  
      img.onerror = null  
      resolve(img)  
    }  

    img.onerror = reject  
    img.src = url  
  })  
}

用户反馈处理

建立用户反馈响应机制:

  • 24 小时内响应严重问题
  • 每周汇总用户反馈
  • 每月发布优化版本

经验总结与建议

技术层面

  1. 组件选择要谨慎优先选择官方组件验证第三方组件兼容性准备备用方案
  2. 性能优化要前置开始就考虑性能问题建立性能监控体系定期进行性能回归测试

流程层面

  1. 证书管理要规范区分调试和发布证书设置证书到期提醒备份证书文件
  2. 审核准备要充分仔细阅读审核指南准备完整的说明材料提前测试审核流程
  3. 监控体系要完善建立崩溃监控收集用户反馈定期分析使用数据

给后来者的建议

  1. 不要畏惧鸿蒙开发uni-app 已经提供了很好的支持大部分代码可以复用社区资源越来越丰富
  2. 从小功能开始尝试先移植简单页面逐步增加复杂度积累经验再处理核心功能
  3. 善用官方资源DCloud 文档很详细华为开发者社区活跃官方技术支持响应快
  4. 加入开发者社区学习他人经验分享自己的收获共同推动生态发展

未来规划

鸿蒙版本的成功上线,给了我们很大信心。下一步计划:

  1. 深度集成鸿蒙特性原子化服务探索分布式能力应用鸿蒙特有 UI 交互
  2. 性能持续优化启动速度优化到 1 秒内内存占用降低 30%功耗优化
  3. 多端协同与 iOS/Android 版本功能同步数据互通体验优化统一的设计语言

写在最后

三个月的鸿蒙适配之旅,让我深刻体会到:技术转型虽然痛苦,但收获远超预期。

最大的收获不是技术上架,而是思维转变:从"web 思维"到"多端思维",从"功能实现"到"体验优化"。

鸿蒙生态还在快速发展,现在入局正是好时机。uni-app 为跨端开发提供了很好的基础,让我们能够以较低成本探索新平台。

如果你也在考虑鸿蒙开发,我的建议是:不要观望,直接开始。从简单的 demo 开始,逐步深入,你会发现鸿蒙开发没有想象中那么难。

在这个过程中,你会遇到各种问题,但也会收获解决问题的成就感。更重要的是,你将成为新生态的早期参与者,这本身就是宝贵的机会。

希望这篇文章能帮助到正在或准备进行鸿蒙开发的你。如果遇到问题,欢迎交流讨论,我们一起推动鸿蒙生态的繁荣!

继续阅读 »

uni-app 打通鸿蒙踩坑日记:从迷茫到上架的完整心路历程

缘起:为什么选择鸿蒙

作为一名前端开发者,我一直在关注各种新兴的技术生态。当华为宣布鸿蒙系统将独立发展时,我就意识到这可能是下一个重要的技术风口。但真正促使我动手的,还是实际的需求痛点。

我们团队有一个成熟的 uni-app 项目,已经在 iOS 和 Android 上稳定运行了两年。但随着华为设备市场份额的不断扩大,越来越多的用户反馈希望有鸿蒙版本。看着应用商店里竞品陆续上线鸿蒙版本,我决定亲自趟一趟这趟"浑水"。

从今年 3 月开始,我花了近三个月时间,完成了从技术调研到实际上架的完整过程。今天就来分享这段充满挑战又收获满满的经历。

技术选型:为什么坚持 uni-app

在开始之前,团队内部有过激烈的讨论:是重新开发原生鸿蒙应用,还是基于现有 uni-app 项目进行适配?

重新开发原生鸿蒙的优势

  • 性能最优,体验最佳
  • 可以充分利用鸿蒙的特有能力
  • 技术栈更纯粹

但最终我们还是选择了 uni-app 适配,原因很现实:

  1. 开发成本:团队熟悉 Vue 技术栈,重新学习 ArkTS 成本太高
  2. 维护成本:三套代码意味着三倍的维护工作量
  3. 迭代速度:业务需求变化快,需要快速响应
  4. DCloud 的承诺:官方明确表示会持续加强鸿蒙支持

事后证明,这个选择是正确的,但过程远比想象中曲折。

环境搭建:第一个坑就栽了跟头

按照官方文档,我信心满满地开始环境搭建。HBuilderX、DevEco Studio、Node.js,一切看起来都很简单。

第一个坑:模拟器选择

文档说"下载 API 19 模拟器即可",但我下载后始终无法启动。各种错误提示看得我头皮发麻:

模拟器启动失败:硬件加速未开启  
VT-x 不可用,请检查 BIOS 设置

我花了整整两天时间:

  • 重启电脑进入 BIOS 开启虚拟化
  • 在 Windows 功能中启用 Hyper-V
  • 更新显卡驱动
  • 重装 DevEco Studio

最后发现是杀毒软件冲突。关闭某数字卫士后,模拟器终于正常启动了。

经验教训:环境问题不要死磕,及时换思路。后来我直接使用真机调试,效率反而更高。

项目迁移:看似顺利的开始

将现有 uni-app 项目迁移到鸿蒙,最初进展出乎意料的顺利。

基础页面适配

大部分 Vue 页面无需修改即可运行,uni-app 的跨端能力确实令人印象深刻:

<template>  
  <view class="container">  
    <text class="title">{{ title }}</text>  
    <button @click="handleClick">点击我</button>  
  </view>  
</template>  

<script>  
export default {  
  data() {  
    return {  
      title: 'Hello HarmonyOS'  
    }  
  },  
  methods: {  
    handleClick() {  
      uni.showToast({  
        title: '点击成功'  
      })  
    }  
  }  
}  
</script>  

<style>  
.container {  
  padding: 20px;  
}  
.title {  
  font-size: 18px;  
  color: #333;  
}  
</style>

路由配置

pages.json的配置也完全兼容:

{  
  "pages": [  
    {  
      "path": "pages/index/index",  
      "style": {  
        "navigationBarTitleText": "首页"  
      }  
    },  
    {  
      "path": "pages/detail/detail",   
      "style": {  
        "navigationBarTitleText": "详情页"  
      }  
    }  
  ]  
}

静态资源

static目录下的图片、字体等资源都能正常加载。前 80% 的工作在两天内就完成了,我甚至觉得鸿蒙开发不过如此。

但接下来的 20%,花了我 80% 的时间。

深水区:平台特定适配

导航栏差异

第一个平台差异出现在导航栏。在 iOS 和 Android 上正常的导航栏,在鸿蒙上出现了错位。

问题现象

  • 标题位置偏移
  • 返回按钮点击区域异常
  • 状态栏颜色不匹配

解决方案

pages.json中为鸿蒙平台单独配置:

{  
  "pages": [  
    {  
      "path": "pages/index/index",  
      "style": {  
        "navigationBarTitleText": "首页",  
        "app-plus": {  
          "titleNView": {  
            // iOS/Android 配置  
          }  
        },  
        "harmony": {  
          "navigationBarBackgroundColor": "#FFFFFF",  
          "navigationBarTextStyle": "black",  
          "navigationBarTitleText": "首页",  
          "navigationStyle": "default"  
        }  
      }  
    }  
  ]  
}

组件兼容性问题

更大的挑战来自组件兼容性。我们项目中使用了大量第三方组件,有些在鸿蒙上表现异常。

案例:图片裁剪组件

在 iOS/Android 上正常的图片裁剪组件,在鸿蒙上出现以下问题:

  • 触摸事件响应错乱
  • 裁剪框位置计算错误
  • 性能卡顿明显

排查过程

  1. 首先怀疑是触摸事件机制差异
  2. 尝试修改事件处理逻辑,效果有限
  3. 深入组件源码,发现使用了 DOM API
  4. 鸿蒙不支持部分 DOM API

最终方案

寻找替代组件,选择明确支持鸿蒙的图片裁剪库。这个过程让我意识到:不是所有 web 生态都能无缝迁移到鸿蒙

API 兼容性

uni-app 的 API 在鸿蒙上的支持程度不一:

完全兼容的

  • uni.showToast
  • uni.request
  • uni.getStorage
  • uni.chooseImage

需要适配的

  • uni.getSystemInfo返回的信息结构不同
  • uni.onKeyboardHeightChange在鸿蒙上触发机制不同
  • 部分设备 API 需要鸿蒙特定实现

不支持的

  • 某些平台特定的扩展 API

我们建立了兼容性检查清单,对每个 API 进行测试:

// 兼容性封装示例  
export const getSafeArea = () => {  
  return new Promise((resolve, reject) => {  
    uni.getSystemInfo({  
      success: (res) => {  
        // 鸿蒙平台特殊处理  
        if (res.platform === 'harmony') {  
          resolve({  
            top: res.statusBarHeight || 0,  
            bottom: 0,  
            left: 0,  
            right: 0  
          })  
        } else {  
          resolve(res.safeArea)  
        }  
      },  
      fail: reject  
    })  
  })  
}

性能优化:从卡顿到流畅

首次在真机上运行应用时,性能问题让我震惊:列表滚动卡顿、页面切换白屏、内存占用过高。

列表性能优化

问题:商品列表页有 1000+ 项目,滚动时严重卡顿。

分析

  • 鸿蒙的列表渲染机制与 web 不同
  • 大量 DOM 节点导致内存压力
  • 图片加载没有做优化

解决方案

  1. 虚拟列表
<template>  
  <view class="list-container">  
    <unicloud-db   
      ref="udb"  
      collection="goods"  
      where="category_id == categoryId"  
      orderby="create_time desc"  
      :page-size="20"  
      @load="onListLoad"  
      v-slot:default="{data, loading, error}">  

      <virtual-list   
        :data="data"  
        :item-size="100"  
        key-field="_id">  
        <template v-slot:default="{item}">  
          <goods-item :item="item" />  
        </template>  
      </virtual-list>  

    </unicloud-db>  
  </view>  
</template>
  1. 图片懒加载
<image   
  :src="item.image"   
  mode="aspectFill"  
  lazy-load  
  :fade-show="false"  
  @load="onImageLoad"  
  @error="onImageError">  
</image>
  1. 内存管理
// 监听页面生命周期  
onPageScroll(e) {  
  // 可视区域外的图片取消加载  
  this.checkVisibleImages()  
},  

onHide() {  
  // 页面隐藏时释放大资源  
  this.clearCache()  
}

启动速度优化

问题:应用冷启动需要 3-5 秒,体验较差。

优化措施

  1. 代码分包
// pages.json  
{  
  "subPackages": [  
    {  
      "root": "pagesA",  
      "pages": [  
        "page1/page1",  
        "page2/page2"  
      ]  
    },  
    {  
      "root": "pagesB",   
      "pages": [  
        "page3/page3",  
        "page4/page4"  
      ]  
    }  
  ]  
}
  1. 预加载关键资源
// app.vue  
onLaunch() {  
  // 预加载首屏必要数据  
  this.preloadEssentialData()  

  // 异步加载非关键资源  
  setTimeout(() => {  
    this.preloadSecondaryResources()  
  }, 1000)  
}
  1. 减少同步操作
// 优化前:同步阻塞  
const userInfo = uni.getStorageSync('userInfo')  
const settings = uni.getStorageSync('settings')  

// 优化后:异步并行  
Promise.all([  
  this.getStorage('userInfo'),  
  this.getStorage('settings')  
]).then(([userInfo, settings]) => {  
  // 处理数据  
})

经过优化后,启动时间缩短到 1-2 秒,列表滚动帧率稳定在 60fps。

打包发布:证书的"坑爹"之旅

如果说代码适配是技术挑战,那证书和打包就是流程挑战。

证书配置的坑

第一个坑:证书类型混淆

我一开始把调试证书当成发布证书使用,结果打包时各种错误:

错误: 证书类型不匹配  
错误: 签名验证失败

解决方案

  • 调试证书:用于开发阶段真机调试
  • 发布证书:用于应用商店上架
  • 两者不能混用

第二个坑:证书有效期

发布证书有一年有效期,但测试证书只有三个月。我第一次提交审核时,证书差点过期。

经验:设置证书到期提醒,提前一个月更新。

打包流程优化

手动打包效率低下,我建立了自动化流程:

// package.json 脚本  
{  
  "scripts": {  
    "build:harmony:debug": "uni build --platform harmony --mode development",  
    "build:harmony:release": "uni build --platform harmony --mode production",  
    "pack:harmony": "node scripts/pack-harmony.js",  
    "deploy:harmony": "npm run build:harmony:release && npm run pack:harmony"  
  }  
}  
// scripts/pack-harmony.js  
const { execSync } = require('child_process')  
const fs = require('fs')  
const path = require('path')  

console.log('🚀 开始鸿蒙应用打包...')  

// 检查构建产物  
const buildPath = path.join(__dirname, '../dist/build/harmony')  
if (!fs.existsSync(buildPath)) {  
  console.error('❌ 构建产物不存在,请先执行构建')  
  process.exit(1)  
}  

// 执行鸿蒙特定打包逻辑  
try {  
  execSync('node scripts/harmony-specific-pack.js', { stdio: 'inherit' })  
  console.log('✅ 鸿蒙应用打包完成')  
} catch (error) {  
  console.error('❌ 打包失败:', error)  
  process.exit(1)  
}

上架审核:意料之外的拒绝

第一次提交审核时,我信心满满,觉得经过充分测试的应用肯定能一次通过。结果被打脸了。

审核拒绝原因

  1. 隐私政策不完整缺少数据存储说明未说明第三方 SDK 数据收集
  2. 权限说明不清晰相机权限用途描述过于简单位置权限必要性未充分说明
  3. 应用截图不规范截图包含状态栏时间"17:30"部分截图尺寸不一致

解决方案

隐私政策完善

## 数据存储说明  
本应用使用本地存储保存用户偏好设置,使用华为云存储备份用户数据。所有数据均加密存储。  

## 第三方 SDK 说明  
- 华为账号 SDK:用于用户登录认证  
- 华为推送 SDK:用于消息推送  
- 微信分享 SDK:用于内容分享

权限说明重写

## 相机权限  
用途:用于扫描商品条形码、拍摄商品照片  
必要性:核心功能需要,无法替代  
数据处理:图片仅在本地处理,不上传服务器  

## 位置权限    
用途:用于推荐附近门店、配送地址定位  
必要性:增值功能需要,非核心功能  
数据处理:位置信息加密传输到服务器

截图规范

  • 使用模拟器统一截图
  • 隐藏状态栏敏感信息
  • 确保所有截图尺寸一致
  • 展示核心功能流程

第二次提交后,顺利通过审核。

监控与反馈:上架只是开始

应用上架后,真正的挑战才开始。用户反馈和线上监控发现了我们测试中未发现的问题。

线上问题排查

案例:特定机型闪退

有用户反馈在华为 Mate 40 上频繁闪退,但我们测试机上正常。

排查过程

  1. 查看 AGC 崩溃日志
  2. 发现内存溢出错误
  3. 定位到图片加载组件
  4. 特定机型内存管理更严格

解决方案

// 图片加载优化  
loadImage(url) {  
  return new Promise((resolve, reject) => {  
    const img = new Image()  

    // 限制图片尺寸  
    if (this.isLowMemoryDevice()) {  
      url = this.addImageSizeParams(url, '800x600')  
    }  

    img.onload = () => {  
      // 及时释放引用  
      img.onload = null  
      img.onerror = null  
      resolve(img)  
    }  

    img.onerror = reject  
    img.src = url  
  })  
}

用户反馈处理

建立用户反馈响应机制:

  • 24 小时内响应严重问题
  • 每周汇总用户反馈
  • 每月发布优化版本

经验总结与建议

技术层面

  1. 组件选择要谨慎优先选择官方组件验证第三方组件兼容性准备备用方案
  2. 性能优化要前置开始就考虑性能问题建立性能监控体系定期进行性能回归测试

流程层面

  1. 证书管理要规范区分调试和发布证书设置证书到期提醒备份证书文件
  2. 审核准备要充分仔细阅读审核指南准备完整的说明材料提前测试审核流程
  3. 监控体系要完善建立崩溃监控收集用户反馈定期分析使用数据

给后来者的建议

  1. 不要畏惧鸿蒙开发uni-app 已经提供了很好的支持大部分代码可以复用社区资源越来越丰富
  2. 从小功能开始尝试先移植简单页面逐步增加复杂度积累经验再处理核心功能
  3. 善用官方资源DCloud 文档很详细华为开发者社区活跃官方技术支持响应快
  4. 加入开发者社区学习他人经验分享自己的收获共同推动生态发展

未来规划

鸿蒙版本的成功上线,给了我们很大信心。下一步计划:

  1. 深度集成鸿蒙特性原子化服务探索分布式能力应用鸿蒙特有 UI 交互
  2. 性能持续优化启动速度优化到 1 秒内内存占用降低 30%功耗优化
  3. 多端协同与 iOS/Android 版本功能同步数据互通体验优化统一的设计语言

写在最后

三个月的鸿蒙适配之旅,让我深刻体会到:技术转型虽然痛苦,但收获远超预期。

最大的收获不是技术上架,而是思维转变:从"web 思维"到"多端思维",从"功能实现"到"体验优化"。

鸿蒙生态还在快速发展,现在入局正是好时机。uni-app 为跨端开发提供了很好的基础,让我们能够以较低成本探索新平台。

如果你也在考虑鸿蒙开发,我的建议是:不要观望,直接开始。从简单的 demo 开始,逐步深入,你会发现鸿蒙开发没有想象中那么难。

在这个过程中,你会遇到各种问题,但也会收获解决问题的成就感。更重要的是,你将成为新生态的早期参与者,这本身就是宝贵的机会。

希望这篇文章能帮助到正在或准备进行鸿蒙开发的你。如果遇到问题,欢迎交流讨论,我们一起推动鸿蒙生态的繁荣!

收起阅读 »

【鸿蒙征文】从零到上线:uni-app vue2适配鸿蒙 NEXT +uts实况窗的实战成长纪实

鸿蒙next 鸿蒙征文

作为一个靠 uni-app 做跨端开发的 “老玩家”,之前已经用它上线了五款 Vue2 的 App —— 不过这次多了个新目标:适配鸿蒙 NEXT 系统。目前成功打包上架鸿蒙两款APP,中间踩了不少坑,也攒了些实打实的经验,今天就把整个过程捋一捋,希望能帮到同样在做鸿蒙适配的朋友。​

一、技术选型:为什么选 uni-app 做鸿蒙开发?​

其实最开始也纠结过要不要用鸿蒙原生开发,但手里的项目是现成的 Vue2 代码,还要兼顾微信小程序 ——uni-app 的 “一次开发多端跑” 刚好戳中需求。后来查了文档才知道,uni-app 对鸿蒙 NEXT 的支持已经比较成熟了,尤其是 UTS 插件能直接调用鸿蒙原生能力,不用从头写原生代码,省了不少事。这也是我最终敲定用 uni-app 做适配的核心原因:能复用老项目代码,还能兼顾跨端,效率比重新开发高太多。​

二、鸿蒙迁移实战

1.手里的 App 都是 Vue2 写的,但鸿蒙只支持 Vue3 的 uni-app 项目,所以第一步就得把 Vue2 迁移到 Vue3。​需要适配vue3包括项目代码和相关组件。
首先是生命周期,Vue2 里的beforeDestroy得改成beforeUnmount,destroyed改成unmounted,一开始没注意,控制台报了一堆错;然后是 v-model,Vue3 里父子组件传值得写成:modelValue="xxx"和@update:modelValue="xxx=$event",之前的写法直接用不了;还有路由,Vue2 里是new VueRouter(),Vue3 得用createRouter和createWebHistory,这些基础语法得逐个核对。​
组件方面,之前用的 Element UI 在 Vue3 里不兼容,只能换成 Element Plus。这里要注意,Element Plus 的引入方式和 Element UI 不一样,得在 main.js 里用createApp(App).use(ElementPlus),还得单独引入样式文件,不然组件样式会乱。我当时漏引了样式,页面加载出来全是裸奔的 HTML 元素,排查了半天才发现问题。

​2.引入harmony-configs,注意里面的client_id的取值。

3.华为登录:必须做的 “硬性要求”​
鸿蒙上架有个规定:只要 App 接入了第三方登录(比如微信、QQ 登录),就必须集成华为登录。不过好在 uni-app 已经封装了华为登录的 API,不用自己写原生代码,直接调用uni.login({provider: 'huawei'})就行。​
但这里有个细节:调用登录前得确保harmony-configs已经初始化,不然会报 “client_id 未配置” 的错。我当时是在 main.js 里先初始化harmony-configs,再调用登录接口,代码大概是这样:​

import harmonyConfigs from '@dcloudio/harmony-configs'​  
// 初始化,client_id从AGC拿​  
harmonyConfigs.init({​  
  clientId: '这里填你的client_id',​  
  scope: 'openid profile email'​  
})​  
​  
// 后续调用华为登录​  
uni.login({​  
  provider: 'huawei',​  
  success: (res) => {​  
    // 拿到code后去后端换token​  
    console.log('华为登录code:', res.code)​  
  },​  
  fail: (err) => {​  
    uni.showToast({ title: '登录失败:' + err.errMsg, icon: 'none' })​  
  }​  
})​

4.发布到鸿蒙开发平台,开发服务中要添加发布证书/调试证书 SHA256证书/公钥指纹。
迁移完代码,就得搭鸿蒙开发环境了。首先得装 DevEco Studio,我下的是 5.0 版本。然后是证书 —— 这是我踩的第二个 “坑”。​
鸿蒙开发需要调试证书和发布证书,还得配置 SHA256 指纹。步骤其实不复杂:先在 DevEco Studio 里生成.p12 格式的密钥库,再用这个密钥库生成.csr 文件,然后去华为 AGC 平台(鸿蒙开发平台)上传.csr,申请发布证书(.cer 格式)和 Profile 文件(.p7b 格式)。最后要在 AGC 的 “应用信息” 里填 SHA256 指纹 —— 这里一定要注意,调试证书和发布证书的指纹要分开填,我一开始把调试指纹当成发布指纹填了,导致后来打包发布版登录失败,折腾了好久才发现。​
另外,引入harmony-configs的时候,里面的client_id得从 AGC 平台拿 —— 在 “我的应用” 里找到对应的 App,进入 “配置” 页面,OAuth 2.0 客户端 ID 就是client_id,填错了后续华为登录会调不通。

5.鸿蒙隐私政策完善:请在相应场景补充HarmonyOS NEXT操作系统的介绍/选项/设备标识符等信息。可参考《审核指南》第1.14项:https://developer.huawei.com/consumer/cn/doc/app/50104-01

三、鸿蒙原生能力:uts实现实况窗接入

鸿蒙 NEXT 的实况窗是个特色功能,我做的App 是计时工具,刚好能用实况窗显示剩余时间,提升用户体验。这里就用到了 uni-app 的 UTS 插件,分享下具体怎么集成的。

UTS 插件结构

uni_modules/harmony-liveview/  
├── package.json          # 插件配置  
├── utssdk/  
│   ├── interface.uts    # TypeScript 接口定义  
│   └── app-harmony/  
│       └── index.uts    # 鸿蒙平台实现  
├── readme.md            # 使用文档  
├── changelog.md         # 更新日志  
└── 使用说明.md          # 本文件

核心 API

  • isLiveViewEnabled() - 检查实况窗是否可用
  • startLiveView() - 启动实况窗
  • updateLiveView() - 更新实况窗显示
  • pauseLiveView() - 暂停/继续计时
  • stopLiveView() - 停止实况窗

完整流程:

1.UTS 插件创建:自己搭了个简单的插件​
我在uni_modules下建了个harmony-liveview文件夹,专门放实况窗相关的代码。结构很简单:utssdk文件夹里放接口定义(interface.uts)和鸿蒙原生实现(app-harmony/index.uts),还有个package.json配置插件信息。​
interface.uts主要定义了几个核心方法的接口,比如检查实况窗是否可用、启动、停止、更新,这样 Vue 组件里调用的时候有类型提示;​
index.uts是真正对接鸿蒙原生 LiveView API 的地方,比如startLiveView方法里,会先检查设备是否支持实况窗,再调用鸿蒙的LiveViewManager.start()启动,还加了定时更新数据的逻辑,默认 10 秒更一次。​

2.uniapp vue组件集成:和业务逻辑绑在一起​
我的计时页面是clock.vue,在里面导入了 UTS 插件的方法,然后把实况窗的启动 / 停止和计时功能绑在一起:用户点 “开始” 按钮,调用startTimer的时候,自动触发startHarmonyLiveView启动实况窗;点 “停止” 的时候,调用stopTimer,同时触发stopHarmonyLiveView停止实况窗。​这里要注意,启动实况窗前得先调用isLiveViewEnabled()检查设备是否支持,不然在不支持的设备上会报错。我还加了个提示,不支持的话就弹个 Toast 告诉用户,体验会好一些。​

3.本地调试,先申请权限也需要添加设备token,参考官方文档,添加后才能进行测试,只有调试没有问题才可以提交单独的实况窗开通审核。

4.实况窗上线:审核比想象中严格​
实况窗要上线,得先在 AGC 申请权限,还得提交设计方案 —— 包括功能场景、界面布局图、资源占用情况。我当时提交的方案里,因为没写清楚 “为什么需要实时更新”,被审核打回来了,后来补充了 “计时工具需要用户在桌面就能看到剩余时间,不用打开 App” 的说明,才通过。​
另外,本地测试的时候,要在 AGC 里添加测试设备的 Token,不然实况窗调不起来。我一开始没加,以为是代码有问题,查了半天文档才知道要配置设备 Token,这点大家要注意。​

UTS代码片段:

参考资料:

  • 鸿蒙 LiveView Kit 官方文档
  • uni-app UTS 插件开发文档
  • https://developer.huawei.com/consumer/cn/codelabsPortal/carddetails/tutorials_NEXT-Live-View-Flight
  • https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/liveview-create-locally

四、上架前的 “避坑指南”:我踩过的 3 个关键问题​

1.发布版白屏:云调试救了我​
本地调试的时候一切正常,但打包发布版提审,鸿蒙审核那边反馈说白屏。我自己下载发布包安装,也遇到了同样的问题,不知道哪里出了错。后来想起 AGC 有个 “云调试” 功能,能选不同型号的鸿蒙设备测试,还能看日志。我用云调试跑了一遍,发现是 SHA256证书/公钥指纹的问题。

2.登录失败:指纹配置错了​
调试版登录没问题,但发布版一直报 “Fingerprint verification error”。查了 AGC 的日志,发现是发布证书的 SHA256 指纹没配置 —— 我之前只填了调试指纹,发布指纹忘了填。补填之后,还得在manifest.json里把versionCode改大一点(比如从 100 改成 101),再让测试的手机把旧版本删掉,不然缓存会导致配置不生效。​

3.鸿蒙备案:提前一周做准备​
鸿蒙上架前必须完成 鸿蒙App 备案。备案提交后,审核界面的校验不是立马生效的,我当时等了差不多 1 天半才显示备案通过。​

五、总结:几个实用的小经验

1.上线前可以用 AGC 的云调试测一遍,很多本地测不出来的问题(比如白屏、登录失败),云调试能快速帮你找到原因;​
2.Vue2 迁 Vue3 的时候,别着急删老代码,先建个分支慢慢改,遇到问题还能回滚;​
3.实况窗的设计方案要写详细,尤其是 “为什么需要这个功能”,不然审核容易被打回;​
4.证书和指纹一定要区分调试版和发布版,别搞混了。

总的来说,用 uni-app 适配鸿蒙 NEXT 的流程不算复杂,只要把 Vue3 迁移、证书配置、华为登录这几个关键点搞定,后续上架就顺理成章了。中间虽然踩了不少坑,但解决问题的过程也是技术成长的过程。希望这篇记录能帮到大家,也欢迎大家在评论区分享自己的适配经验,一起交流进步!

继续阅读 »

作为一个靠 uni-app 做跨端开发的 “老玩家”,之前已经用它上线了五款 Vue2 的 App —— 不过这次多了个新目标:适配鸿蒙 NEXT 系统。目前成功打包上架鸿蒙两款APP,中间踩了不少坑,也攒了些实打实的经验,今天就把整个过程捋一捋,希望能帮到同样在做鸿蒙适配的朋友。​

一、技术选型:为什么选 uni-app 做鸿蒙开发?​

其实最开始也纠结过要不要用鸿蒙原生开发,但手里的项目是现成的 Vue2 代码,还要兼顾微信小程序 ——uni-app 的 “一次开发多端跑” 刚好戳中需求。后来查了文档才知道,uni-app 对鸿蒙 NEXT 的支持已经比较成熟了,尤其是 UTS 插件能直接调用鸿蒙原生能力,不用从头写原生代码,省了不少事。这也是我最终敲定用 uni-app 做适配的核心原因:能复用老项目代码,还能兼顾跨端,效率比重新开发高太多。​

二、鸿蒙迁移实战

1.手里的 App 都是 Vue2 写的,但鸿蒙只支持 Vue3 的 uni-app 项目,所以第一步就得把 Vue2 迁移到 Vue3。​需要适配vue3包括项目代码和相关组件。
首先是生命周期,Vue2 里的beforeDestroy得改成beforeUnmount,destroyed改成unmounted,一开始没注意,控制台报了一堆错;然后是 v-model,Vue3 里父子组件传值得写成:modelValue="xxx"和@update:modelValue="xxx=$event",之前的写法直接用不了;还有路由,Vue2 里是new VueRouter(),Vue3 得用createRouter和createWebHistory,这些基础语法得逐个核对。​
组件方面,之前用的 Element UI 在 Vue3 里不兼容,只能换成 Element Plus。这里要注意,Element Plus 的引入方式和 Element UI 不一样,得在 main.js 里用createApp(App).use(ElementPlus),还得单独引入样式文件,不然组件样式会乱。我当时漏引了样式,页面加载出来全是裸奔的 HTML 元素,排查了半天才发现问题。

​2.引入harmony-configs,注意里面的client_id的取值。

3.华为登录:必须做的 “硬性要求”​
鸿蒙上架有个规定:只要 App 接入了第三方登录(比如微信、QQ 登录),就必须集成华为登录。不过好在 uni-app 已经封装了华为登录的 API,不用自己写原生代码,直接调用uni.login({provider: 'huawei'})就行。​
但这里有个细节:调用登录前得确保harmony-configs已经初始化,不然会报 “client_id 未配置” 的错。我当时是在 main.js 里先初始化harmony-configs,再调用登录接口,代码大概是这样:​

import harmonyConfigs from '@dcloudio/harmony-configs'​  
// 初始化,client_id从AGC拿​  
harmonyConfigs.init({​  
  clientId: '这里填你的client_id',​  
  scope: 'openid profile email'​  
})​  
​  
// 后续调用华为登录​  
uni.login({​  
  provider: 'huawei',​  
  success: (res) => {​  
    // 拿到code后去后端换token​  
    console.log('华为登录code:', res.code)​  
  },​  
  fail: (err) => {​  
    uni.showToast({ title: '登录失败:' + err.errMsg, icon: 'none' })​  
  }​  
})​

4.发布到鸿蒙开发平台,开发服务中要添加发布证书/调试证书 SHA256证书/公钥指纹。
迁移完代码,就得搭鸿蒙开发环境了。首先得装 DevEco Studio,我下的是 5.0 版本。然后是证书 —— 这是我踩的第二个 “坑”。​
鸿蒙开发需要调试证书和发布证书,还得配置 SHA256 指纹。步骤其实不复杂:先在 DevEco Studio 里生成.p12 格式的密钥库,再用这个密钥库生成.csr 文件,然后去华为 AGC 平台(鸿蒙开发平台)上传.csr,申请发布证书(.cer 格式)和 Profile 文件(.p7b 格式)。最后要在 AGC 的 “应用信息” 里填 SHA256 指纹 —— 这里一定要注意,调试证书和发布证书的指纹要分开填,我一开始把调试指纹当成发布指纹填了,导致后来打包发布版登录失败,折腾了好久才发现。​
另外,引入harmony-configs的时候,里面的client_id得从 AGC 平台拿 —— 在 “我的应用” 里找到对应的 App,进入 “配置” 页面,OAuth 2.0 客户端 ID 就是client_id,填错了后续华为登录会调不通。

5.鸿蒙隐私政策完善:请在相应场景补充HarmonyOS NEXT操作系统的介绍/选项/设备标识符等信息。可参考《审核指南》第1.14项:https://developer.huawei.com/consumer/cn/doc/app/50104-01

三、鸿蒙原生能力:uts实现实况窗接入

鸿蒙 NEXT 的实况窗是个特色功能,我做的App 是计时工具,刚好能用实况窗显示剩余时间,提升用户体验。这里就用到了 uni-app 的 UTS 插件,分享下具体怎么集成的。

UTS 插件结构

uni_modules/harmony-liveview/  
├── package.json          # 插件配置  
├── utssdk/  
│   ├── interface.uts    # TypeScript 接口定义  
│   └── app-harmony/  
│       └── index.uts    # 鸿蒙平台实现  
├── readme.md            # 使用文档  
├── changelog.md         # 更新日志  
└── 使用说明.md          # 本文件

核心 API

  • isLiveViewEnabled() - 检查实况窗是否可用
  • startLiveView() - 启动实况窗
  • updateLiveView() - 更新实况窗显示
  • pauseLiveView() - 暂停/继续计时
  • stopLiveView() - 停止实况窗

完整流程:

1.UTS 插件创建:自己搭了个简单的插件​
我在uni_modules下建了个harmony-liveview文件夹,专门放实况窗相关的代码。结构很简单:utssdk文件夹里放接口定义(interface.uts)和鸿蒙原生实现(app-harmony/index.uts),还有个package.json配置插件信息。​
interface.uts主要定义了几个核心方法的接口,比如检查实况窗是否可用、启动、停止、更新,这样 Vue 组件里调用的时候有类型提示;​
index.uts是真正对接鸿蒙原生 LiveView API 的地方,比如startLiveView方法里,会先检查设备是否支持实况窗,再调用鸿蒙的LiveViewManager.start()启动,还加了定时更新数据的逻辑,默认 10 秒更一次。​

2.uniapp vue组件集成:和业务逻辑绑在一起​
我的计时页面是clock.vue,在里面导入了 UTS 插件的方法,然后把实况窗的启动 / 停止和计时功能绑在一起:用户点 “开始” 按钮,调用startTimer的时候,自动触发startHarmonyLiveView启动实况窗;点 “停止” 的时候,调用stopTimer,同时触发stopHarmonyLiveView停止实况窗。​这里要注意,启动实况窗前得先调用isLiveViewEnabled()检查设备是否支持,不然在不支持的设备上会报错。我还加了个提示,不支持的话就弹个 Toast 告诉用户,体验会好一些。​

3.本地调试,先申请权限也需要添加设备token,参考官方文档,添加后才能进行测试,只有调试没有问题才可以提交单独的实况窗开通审核。

4.实况窗上线:审核比想象中严格​
实况窗要上线,得先在 AGC 申请权限,还得提交设计方案 —— 包括功能场景、界面布局图、资源占用情况。我当时提交的方案里,因为没写清楚 “为什么需要实时更新”,被审核打回来了,后来补充了 “计时工具需要用户在桌面就能看到剩余时间,不用打开 App” 的说明,才通过。​
另外,本地测试的时候,要在 AGC 里添加测试设备的 Token,不然实况窗调不起来。我一开始没加,以为是代码有问题,查了半天文档才知道要配置设备 Token,这点大家要注意。​

UTS代码片段:

参考资料:

  • 鸿蒙 LiveView Kit 官方文档
  • uni-app UTS 插件开发文档
  • https://developer.huawei.com/consumer/cn/codelabsPortal/carddetails/tutorials_NEXT-Live-View-Flight
  • https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/liveview-create-locally

四、上架前的 “避坑指南”:我踩过的 3 个关键问题​

1.发布版白屏:云调试救了我​
本地调试的时候一切正常,但打包发布版提审,鸿蒙审核那边反馈说白屏。我自己下载发布包安装,也遇到了同样的问题,不知道哪里出了错。后来想起 AGC 有个 “云调试” 功能,能选不同型号的鸿蒙设备测试,还能看日志。我用云调试跑了一遍,发现是 SHA256证书/公钥指纹的问题。

2.登录失败:指纹配置错了​
调试版登录没问题,但发布版一直报 “Fingerprint verification error”。查了 AGC 的日志,发现是发布证书的 SHA256 指纹没配置 —— 我之前只填了调试指纹,发布指纹忘了填。补填之后,还得在manifest.json里把versionCode改大一点(比如从 100 改成 101),再让测试的手机把旧版本删掉,不然缓存会导致配置不生效。​

3.鸿蒙备案:提前一周做准备​
鸿蒙上架前必须完成 鸿蒙App 备案。备案提交后,审核界面的校验不是立马生效的,我当时等了差不多 1 天半才显示备案通过。​

五、总结:几个实用的小经验

1.上线前可以用 AGC 的云调试测一遍,很多本地测不出来的问题(比如白屏、登录失败),云调试能快速帮你找到原因;​
2.Vue2 迁 Vue3 的时候,别着急删老代码,先建个分支慢慢改,遇到问题还能回滚;​
3.实况窗的设计方案要写详细,尤其是 “为什么需要这个功能”,不然审核容易被打回;​
4.证书和指纹一定要区分调试版和发布版,别搞混了。

总的来说,用 uni-app 适配鸿蒙 NEXT 的流程不算复杂,只要把 Vue3 迁移、证书配置、华为登录这几个关键点搞定,后续上架就顺理成章了。中间虽然踩了不少坑,但解决问题的过程也是技术成长的过程。希望这篇记录能帮到大家,也欢迎大家在评论区分享自己的适配经验,一起交流进步!

收起阅读 »

【鸿蒙征文】开源支持鸿蒙!uView Pro 开源三个月,近期更新全面大盘点,及未来计划

鸿蒙征文

uView Pro 开源近三个月以来,收到了良好的反馈和迭代。目前 uView Pro 已经迭代了 40+ 个版本,平均每两天就会发布版本,主要是优化性能、新增\增强组件功能、bug修复、兼容性完善等。

所以目前 uView Pro 在稳定性、功能性与跨平台兼容性方面已经有了良好的表现。主要实现了 APP、鸿蒙、微信、支付宝、头条等小程序平台的兼容,后续也会继续进行迭代。

本文基于最近的 changelog 汇总,面向开发者与项目贡献者,系统介绍新增组件、关键修复、工具能力以及如何在项目中快速体验这些特性,并提供示例代码与资源链接,方便你在实际工程中落地使用。

一、总体概览

目前最新版本(0.3.15 及此前若干小版本)覆盖三大方向:

  • 平台兼容与 bug 修复:适配更多小程序平台(包括鸿蒙/各小程序支持的完善),修复了 canvas 渲染、表单响应、picker 初始化、组件兼容性等若干跨端问题。
  • 新组件与用户体验优化:推出并增强若干特色组件,如 u-fab(悬浮按钮)、u-textu-loading-popupu-textareau-safe-bottomu-status-baru-root-portal,以满足常见 UI 场景需求。
  • 工具链与框架能力:增强 http 插件与 useCompRelation(组件关系管理 Hooks),使业务层网络请求与复杂组件协作更便捷。

接下来我们把重点放在新增与优化的功能、示例使用以及工程实践建议上。

详情可查看官网及近期更新日志:https://uviewpro.cn/

二、亮点功能与新增组件(逐个拆解)

1) u-fab(悬浮按钮)

简介:u-fab 是面向移动端常见的悬浮操作入口,支持多种预设定位、拖动吸边(autoStick)以及 gap 属性的精细化配置。该组件在交互与无障碍体验上进行了增强,能兼容多端布局差异。

主要特性:

  • 预设 position(如右下、左下、右中等)便于在不同 UI 布局中快速放置。
  • 支持 gap 的对象式配置(top/right/bottom/left),使 demo 与真实项目兼容性更好。
  • autoStick:拖动后自动吸边,提升交互体验。

示例:

示例(Vue 3 Composition API):

<template>  
    <u-fab position="right-bottom" :gap="gapObj" :draggable="true" :autoStick="true">  
        <template #default>  
            <u-button shape="circle" size="mini" type="primary" @click="onFabClick">  
                <u-icon name="thumb-up" size="40"></u-icon>  
            </u-button>  
        </template>  
    </u-fab>  
</template>  

<script setup lang="ts">  
import { ref } from 'vue';  
const gapObj = { top: 20, right: 16, bottom: 16, left: 16 };  
function onFabClick() {  
    uni.showToast({ title: '悬浮按钮点击' });  
}  
</script>

建议:在移动端应结合 safe area(如 u-safe-bottom)与页面常驻按钮布局谨慎使用 u-fab,避免遮挡关键内容。

更多用法请参考文档:https://uviewpro.cn/zh/components/fab.html

2) u-text

简介:u-text 提供更灵活的文字样式与插槽支持,能在长文本、富文本展示场景中替代常规标签并统一样式控制。

主要特性:

  • 支持默认插槽与多种文本截断/换行策略。
  • 更友好的样式穿透能力,方便主题化。

示例:

<!-- 主题颜色文字 -->  
<u-text text="主色文字" type="primary"></u-text>  

<!-- 拨打电话 -->  
<u-text mode="phone" text="15019479320"></u-text>  

<!-- 日期格式化 -->  
<u-text mode="date" text="1612959739"></u-text>  

<!-- 超链接 -->  
<u-text mode="link" text="Go to uView Pro docs" href="https://uviewpro.cn"></u-text>  

<!-- 姓名脱敏 -->  
<u-text mode="name" text="张三三" format="encrypt"></u-text>  

<!-- 显示金额 -->  
<u-text mode="price" text="728732.32"></u-text>  

<!-- 默认插槽 -->  
<u-text class="desc">这是一个示例文本,支持自定义插槽与样式</u-text>  

更多用法请参考文档:https://uviewpro.cn/zh/components/text.html

3) u-loading-popup

简介:一个可配置的加载弹窗组件,支持多种加载风格与遮罩配置,方便替代项目中散落的 loading 逻辑。

示例(最小用法):

<!-- 默认纵向加载 -->  
<u-loading-popup v-model="loading" text="正在加载..." />  
<!-- 横向加载 -->  
<u-loading-popup v-model="loading" direction="horizontal" text="正在加载..." />

更多用法请参考文档:https://uviewpro.cn/zh/components/loadingPopup.html

4) u-textarea

简介:独立的 u-textarea 组件从 u-input 中拆分而来,增强了字数统计、伸缩、和独立样式控制能力,满足复杂表单与长文本输入场景。

示例:

<!-- 字数统计 -->  
<u-textarea v-model="content" :maxlength="500" count />  

<!-- 自动高度 -->  
<u-textarea v-model="content" placeholder="请输入内容" autoHeight></u-textarea>

更多用法请参考文档:https://uviewpro.cn/zh/components/textarea.html

5) u-safe-bottom 与 u-status-bar

用途:与设备安全区(notch/safearea)相关的布局组件,用来保证底部/状态栏的展示在不同平台上都不会被遮挡或错位。适配了多端差异(iOS、Android、不同小程序宿主)。

如果有需要,您可以在任何地方引用它,它会自动判断在并且在 IPhone X 等机型的时候,给元素加上一个适当 底部内边距,在 APP 上,即使您保留了原生安全区占位(offset设置为auto),也不会导致底部出现双倍的空白区域,也即 APP 上 offset 设置为 auto 时。

<template>  
  <view>  
    ......  
    <u-safe-bottom></u-safe-bottom>  
  </view>  
</template>

更多用法请参考文档:https://uviewpro.cn/zh/components/safeAreaInset.html

6) u-root-portal

简介:提供将节点传送到根节点的能力(Portal 模式),适用于模态、全局浮层等需要脱离当前 dom 层级的场景,兼容多端实现细节。

根节点传送组件仅支持微信小程序、支付宝小程序、APP和H5平台,组件会自动根据平台选择合适的实现方式:

这类场景最常见的例子就是全屏的模态框。理想情况下,我们希望触发模态框的按钮和模态框本身的代码是在同一个单文件组件中,因为它们都与组件的开关状态有关。

<u-button type="primary" @click="show = true">显示弹窗</u-button>  
<u-root-portal v-if="show">  
  <view class="modal">  
    <view class="modal-content">  
      <text>这是一个全局弹窗</text>  
      <u-button @click="show = false">关闭</u-button>  
    </view>  
  </view>  
</u-root-portal>

更多用法请参考文档:https://uviewpro.cn/zh/components/rootPortal.html

7) 自定义主题

uView Pro 目前可以自定主题色,字体颜色,边框颜色等,所有组件内部的样式,都基于同一套主题,比如您修改了primary主题色,所有用到了primary颜色 的组件都会受影响。

由于 uView 官方版本,组件内部存在许多硬编码颜色配置,无法动态根据 scss 变量,现在,我们可以统一跟随主题配置了。

通过官网主题颜色配置完后,在页面底部下载文件,会得到一个名为uview-pro.theme.scssuview-pro.theme.ts的文件。

配置 scss 变量

/* uni.scss */  
@import 'uview-pro/theme.scss';

配置 ts 变量

// main.ts  
import { createSSRApp } from 'vue'  
import App from './App.vue'  
import theme from '@/common/uview-pro.theme'  
import uViewPro from 'uview-pro'  

export function createApp() {  
  const app = createSSRApp(App)  
  // 引入uView Pro 主库,及theme主题  
  app.use(uViewPro, { theme })  
  return {  
    app  
  }  
}

以上步骤完成之后,所有颜色均跟随主题色。

更多用法请参考文档:https://uviewpro.cn/zh/guide/theme.html

8) 自定义样式

uView Pro 默认提供了一套美观且统一的组件样式,但在实际项目开发中,往往需要根据业务需求进行个性化定制。参考自定义主题。

然而,如果仅是需要覆盖组件的默认样式,或增加样式,uView Pro 则支持两种主流的自定义样式方式,灵活满足各种场景:

目前,所有组件均支持 custom-class 样式穿透和 custom-style 内联样式

<view class="my-page">  
    <!-- custom-class 样式穿透 -->  
    <u-button custom-class="my-btn"></u-button>  

    <!-- 自定义内联样式 -->  
    <u-button  
        custom-style="background: linear-gradient(90deg,#2979ff,#00c6ff);color:#fff;border-radius:8px;"  
    ></u-button>  
</view>  

<style lang="scss">  
.my-page {  
  :deep(.my-btn) {  
    background-color: #2979ff;  
    color: #fff;  
    border-radius: 8px;  
  }  
}  
</style>  

更多用法请参考文档:https://uviewpro.cn/zh/guide/style.html

三、工具链改进与新能力

1) http 插件(httpPlugin)

简介:提供统一的请求封装,支持 TypeScript、Vue3、组合式 API,插件化、全局配置、请求/响应拦截器、请求元信息类型(toast/loading 灵活控制),开箱即用,便于在项目中进行全局化网络管理。。

示例:基本请求

import { http } from 'uview-pro'  

// GET  
http.get('/api/user', { id: 1 }).then(res => {  
  /* ... */  
})  

// POST  
http.post('/api/login', { username: 'xx', password: 'xx' }).then(res => {  
  /* ... */  
})  

// PUT/DELETE  
http.put('/api/user/1', { name: 'new' })  
http.delete('/api/user/1')

高级:支持请求拦截器、全局错误处理与 meta 配置,适合接入鉴权、重试、限流等策略。

最佳实践:定义拦截器配置 => 注册拦截器 => 统一 API 管理

定义拦截器配置

import type { RequestConfig, RequestInterceptor, RequestMeta, RequestOptions } from 'uview-pro'  
import { useUserStore } from '@/store'  

// 全局请求配置  
export const httpRequestConfig: RequestConfig = {  
  baseUrl,  
  header: {  
    'content-type': 'application/json'  
  },  
  meta: {  
    originalData: true,  
    toast: true,  
    loading: true  
  }  
}  

// 全局请求/响应拦截器  
export const httpInterceptor: RequestInterceptor = {  
  request: (config: RequestOptions) => {  
    // 请求拦截  
    return config  
  },  
  response: (response: any) => {  
    // 响应拦截  
    return response.data  
  }  
}

注册拦截器:

import { createSSRApp } from 'vue'  
import uViewPro, { httpPlugin } from 'uview-pro'  
import { httpInterceptor, httpRequestConfig } from 'http.interceptor'  

export function createApp() {  
  const app = createSSRApp(App)  

  // 注册uView-pro  
  app.use(uViewPro)  

  // 注册http插件  
  app.use(httpPlugin, {  
    interceptor: httpInterceptor,  
    requestConfig: httpRequestConfig  
  })  

  return { app }  
}

统一 API 管理

// api/index.ts  
import { http } from 'uview-pro'  

export const login = data => http.post('/api/login', data,  { meta: { loading: true, toast: true } })  
export const getUser = id => http.get('/api/user', { id },  { meta: { loading: false } })

以上示例为经典最佳实践,更多用法请查看 http 插件文档:https://uviewpro.cn/zh/tools/http.html

2) useCompRelation(组件关系管理 Hooks)

目的:替代传统的 provide/inject 在多平台(尤其是一些小程序宿主)可能存在的兼容问题,提供更可靠的父子组件连接和事件广播机制。

应用场景:复杂表单、级联菜单、带有子项动态增删的组件集合等。

父组件示例(伪代码):

import { useParent } from 'uview-pro';  

const { children, broadcast } = useParent('u-dropdown');  

// 广播调用子组件函数  
broadcast('childFunctionName', { payload });  

// 收集所有子组件指定值  
function getChildrenValues() {  
    let values: any[] = [];  
    children.forEach((child: any) => {  
        if (child.getExposed?.()?.isChecked.value) {  
            values.push(child.getExposed?.()?.name);  
        }  
    });  
}

子组件示例(伪代码):

const { parentExposed, emitToParent } = useChildren('u-dropdown-item', 'u-dropdown');  

// 触发父组件的函数  
emitToParent('parentFunctionName');  

// 获取父组件的变量  
const activeColor = computed(() => parentExposed.value?.activeColor);

更多用法请参考组件源码:useCompRelation.ts

3) 提供 llms.txt

llms.txt的作用是什么,一般它用来告诉大模型是否允许抓取网站数据用于训练的文件,类似于 robots.txt 控制爬虫权限,因此 uView Pro 也提供了即时更新的 llms.txt 文件,便于训练大模型,更好的为我们服务,链接如下:

https://uviewpro.cn/llms.txt

https://uviewpro.cn/llms-full.txt

四、多脚手架支持

1) create-uni

create-uni 提供一键生成、模板丰富的项目引导能力,旨在增强 uni-app 系列产品的开发体验,官网:https://uni-helper.cn/create-uni/core

pnpm create uni <项目名称> --ts -m pinia -m unocss -u uview-pro -e

表示:

  • 启用 TypeScript
  • 集成 ESLint 代码规范
  • 启用 pinia
  • 集成 unocss
  • 选择 uview-pro组件库

如果你想用 create-uni 交互式创建一个项目,请执行以下命令:

pnpm create uni

进入交互式选择界面,选择 uView Pro 模板或组件,其他的相关插件可按需选择:

使用 create-uni 快速创建 uView Pro Starter 启动模板,请执行以下命令:

pnpm create uni <项目名称> -t uview-pro-starter

使用 create-uni 快速创建 uView Pro 完整组件演示模板,请执行以下命令:

pnpm create uni <项目名称> -t uview-pro-demo

2) unibest

unibest 是目前最火的 uni-app 脚手架,它是菲鸽大佬联同众多 uni-app 开发者共同贡献的 uni-app 框架,集成了最新技术栈和开发工具,官网:https://unibest.tech/

如果你想用 unibest 和 uView Pro 来创建项目,请执行以下命令:

一行代码创建项目:

pnpm create unibest <项目名称> -t base-uview-pro

交互式创建项目:

pnpm create unibest

选择 base-uview-pro 模板:

3) 官方cli

第一种:创建以 javascript 开发的工程

npx degit dcloudio/uni-preset-vue#vite my-vue3-project

第二种:创建以 typescript 开发的工程

npx degit dcloudio/uni-preset-vue#vite-ts my-vue3-project

引入uview—pro组件库即可,不再过多介绍,可参考快速配置:https://uviewpro.cn/zh/components/setting.html

五、近期修复若干关键问题

  • u-circle-progress 的 canvas 渲染问题已修复,解决了微信小程序 canvas 2D 在不同平台上下文差异导致的绘制异常。
  • u-form 相关多个修复:处理 model 替换导致校验失效、resetFields 修复、u-form-item 样式与光标问题修复,提升表单在小程序端兼容性。
  • picker、index-list、popup 等组件的跨端兼容修复,减少在头条/支付宝/微信等宿主上的差异表现。

这些修复的综合效果是:在多端使用 uView‑Pro 构建页面时,出现的平台差异与边缘 bug 大幅减少,开发成本降低。

六、跨平台支持说明

当前 uView‑Pro 已兼容并在以下平台进行适配与测试:

  • 鸿蒙(HarmonyOS)
  • Android(原生应用及 WebView)
  • iOS(原生应用及 WebView)
  • 微信小程序
  • 支付宝小程序
  • 头条小程序

后续仍然会对多端小程序兼容性的持续投入,很多修复直接针对宿主差异展开(例如 Canvas 行为、provide/inject 实现差异、样式差异等)。

近期在鸿蒙6.0系统上运行uView Pro源码,效果还不错,如下:

七、未来计划

根据规划,未来几个方向包括:

  • 持续优化现有组件,新增组件,提升用户体验
  • 国际化(i18n)支持:统一组件的语言切换能力,方便多语言产品线接入。
  • 暗黑模式(Dark Mode):与运行时主题切换能力结合,提供暗色皮肤一键切换体验。
  • 优化现有平台兼容性,扩展更多平台的适配测试(保持对小程序宿主的兼容修复)。
  • uni-app x 支持:目前还在调研中。
  • mcp 支持。

八、结语

如果你在项目中使用到以上组件或工具,并希望参与贡献,请参考仓库的贡献指南。欢迎提 issue、提交 PR,或在插件市场与社区中反馈使用体验。

继续阅读 »

uView Pro 开源近三个月以来,收到了良好的反馈和迭代。目前 uView Pro 已经迭代了 40+ 个版本,平均每两天就会发布版本,主要是优化性能、新增\增强组件功能、bug修复、兼容性完善等。

所以目前 uView Pro 在稳定性、功能性与跨平台兼容性方面已经有了良好的表现。主要实现了 APP、鸿蒙、微信、支付宝、头条等小程序平台的兼容,后续也会继续进行迭代。

本文基于最近的 changelog 汇总,面向开发者与项目贡献者,系统介绍新增组件、关键修复、工具能力以及如何在项目中快速体验这些特性,并提供示例代码与资源链接,方便你在实际工程中落地使用。

一、总体概览

目前最新版本(0.3.15 及此前若干小版本)覆盖三大方向:

  • 平台兼容与 bug 修复:适配更多小程序平台(包括鸿蒙/各小程序支持的完善),修复了 canvas 渲染、表单响应、picker 初始化、组件兼容性等若干跨端问题。
  • 新组件与用户体验优化:推出并增强若干特色组件,如 u-fab(悬浮按钮)、u-textu-loading-popupu-textareau-safe-bottomu-status-baru-root-portal,以满足常见 UI 场景需求。
  • 工具链与框架能力:增强 http 插件与 useCompRelation(组件关系管理 Hooks),使业务层网络请求与复杂组件协作更便捷。

接下来我们把重点放在新增与优化的功能、示例使用以及工程实践建议上。

详情可查看官网及近期更新日志:https://uviewpro.cn/

二、亮点功能与新增组件(逐个拆解)

1) u-fab(悬浮按钮)

简介:u-fab 是面向移动端常见的悬浮操作入口,支持多种预设定位、拖动吸边(autoStick)以及 gap 属性的精细化配置。该组件在交互与无障碍体验上进行了增强,能兼容多端布局差异。

主要特性:

  • 预设 position(如右下、左下、右中等)便于在不同 UI 布局中快速放置。
  • 支持 gap 的对象式配置(top/right/bottom/left),使 demo 与真实项目兼容性更好。
  • autoStick:拖动后自动吸边,提升交互体验。

示例:

示例(Vue 3 Composition API):

<template>  
    <u-fab position="right-bottom" :gap="gapObj" :draggable="true" :autoStick="true">  
        <template #default>  
            <u-button shape="circle" size="mini" type="primary" @click="onFabClick">  
                <u-icon name="thumb-up" size="40"></u-icon>  
            </u-button>  
        </template>  
    </u-fab>  
</template>  

<script setup lang="ts">  
import { ref } from 'vue';  
const gapObj = { top: 20, right: 16, bottom: 16, left: 16 };  
function onFabClick() {  
    uni.showToast({ title: '悬浮按钮点击' });  
}  
</script>

建议:在移动端应结合 safe area(如 u-safe-bottom)与页面常驻按钮布局谨慎使用 u-fab,避免遮挡关键内容。

更多用法请参考文档:https://uviewpro.cn/zh/components/fab.html

2) u-text

简介:u-text 提供更灵活的文字样式与插槽支持,能在长文本、富文本展示场景中替代常规标签并统一样式控制。

主要特性:

  • 支持默认插槽与多种文本截断/换行策略。
  • 更友好的样式穿透能力,方便主题化。

示例:

<!-- 主题颜色文字 -->  
<u-text text="主色文字" type="primary"></u-text>  

<!-- 拨打电话 -->  
<u-text mode="phone" text="15019479320"></u-text>  

<!-- 日期格式化 -->  
<u-text mode="date" text="1612959739"></u-text>  

<!-- 超链接 -->  
<u-text mode="link" text="Go to uView Pro docs" href="https://uviewpro.cn"></u-text>  

<!-- 姓名脱敏 -->  
<u-text mode="name" text="张三三" format="encrypt"></u-text>  

<!-- 显示金额 -->  
<u-text mode="price" text="728732.32"></u-text>  

<!-- 默认插槽 -->  
<u-text class="desc">这是一个示例文本,支持自定义插槽与样式</u-text>  

更多用法请参考文档:https://uviewpro.cn/zh/components/text.html

3) u-loading-popup

简介:一个可配置的加载弹窗组件,支持多种加载风格与遮罩配置,方便替代项目中散落的 loading 逻辑。

示例(最小用法):

<!-- 默认纵向加载 -->  
<u-loading-popup v-model="loading" text="正在加载..." />  
<!-- 横向加载 -->  
<u-loading-popup v-model="loading" direction="horizontal" text="正在加载..." />

更多用法请参考文档:https://uviewpro.cn/zh/components/loadingPopup.html

4) u-textarea

简介:独立的 u-textarea 组件从 u-input 中拆分而来,增强了字数统计、伸缩、和独立样式控制能力,满足复杂表单与长文本输入场景。

示例:

<!-- 字数统计 -->  
<u-textarea v-model="content" :maxlength="500" count />  

<!-- 自动高度 -->  
<u-textarea v-model="content" placeholder="请输入内容" autoHeight></u-textarea>

更多用法请参考文档:https://uviewpro.cn/zh/components/textarea.html

5) u-safe-bottom 与 u-status-bar

用途:与设备安全区(notch/safearea)相关的布局组件,用来保证底部/状态栏的展示在不同平台上都不会被遮挡或错位。适配了多端差异(iOS、Android、不同小程序宿主)。

如果有需要,您可以在任何地方引用它,它会自动判断在并且在 IPhone X 等机型的时候,给元素加上一个适当 底部内边距,在 APP 上,即使您保留了原生安全区占位(offset设置为auto),也不会导致底部出现双倍的空白区域,也即 APP 上 offset 设置为 auto 时。

<template>  
  <view>  
    ......  
    <u-safe-bottom></u-safe-bottom>  
  </view>  
</template>

更多用法请参考文档:https://uviewpro.cn/zh/components/safeAreaInset.html

6) u-root-portal

简介:提供将节点传送到根节点的能力(Portal 模式),适用于模态、全局浮层等需要脱离当前 dom 层级的场景,兼容多端实现细节。

根节点传送组件仅支持微信小程序、支付宝小程序、APP和H5平台,组件会自动根据平台选择合适的实现方式:

这类场景最常见的例子就是全屏的模态框。理想情况下,我们希望触发模态框的按钮和模态框本身的代码是在同一个单文件组件中,因为它们都与组件的开关状态有关。

<u-button type="primary" @click="show = true">显示弹窗</u-button>  
<u-root-portal v-if="show">  
  <view class="modal">  
    <view class="modal-content">  
      <text>这是一个全局弹窗</text>  
      <u-button @click="show = false">关闭</u-button>  
    </view>  
  </view>  
</u-root-portal>

更多用法请参考文档:https://uviewpro.cn/zh/components/rootPortal.html

7) 自定义主题

uView Pro 目前可以自定主题色,字体颜色,边框颜色等,所有组件内部的样式,都基于同一套主题,比如您修改了primary主题色,所有用到了primary颜色 的组件都会受影响。

由于 uView 官方版本,组件内部存在许多硬编码颜色配置,无法动态根据 scss 变量,现在,我们可以统一跟随主题配置了。

通过官网主题颜色配置完后,在页面底部下载文件,会得到一个名为uview-pro.theme.scssuview-pro.theme.ts的文件。

配置 scss 变量

/* uni.scss */  
@import 'uview-pro/theme.scss';

配置 ts 变量

// main.ts  
import { createSSRApp } from 'vue'  
import App from './App.vue'  
import theme from '@/common/uview-pro.theme'  
import uViewPro from 'uview-pro'  

export function createApp() {  
  const app = createSSRApp(App)  
  // 引入uView Pro 主库,及theme主题  
  app.use(uViewPro, { theme })  
  return {  
    app  
  }  
}

以上步骤完成之后,所有颜色均跟随主题色。

更多用法请参考文档:https://uviewpro.cn/zh/guide/theme.html

8) 自定义样式

uView Pro 默认提供了一套美观且统一的组件样式,但在实际项目开发中,往往需要根据业务需求进行个性化定制。参考自定义主题。

然而,如果仅是需要覆盖组件的默认样式,或增加样式,uView Pro 则支持两种主流的自定义样式方式,灵活满足各种场景:

目前,所有组件均支持 custom-class 样式穿透和 custom-style 内联样式

<view class="my-page">  
    <!-- custom-class 样式穿透 -->  
    <u-button custom-class="my-btn"></u-button>  

    <!-- 自定义内联样式 -->  
    <u-button  
        custom-style="background: linear-gradient(90deg,#2979ff,#00c6ff);color:#fff;border-radius:8px;"  
    ></u-button>  
</view>  

<style lang="scss">  
.my-page {  
  :deep(.my-btn) {  
    background-color: #2979ff;  
    color: #fff;  
    border-radius: 8px;  
  }  
}  
</style>  

更多用法请参考文档:https://uviewpro.cn/zh/guide/style.html

三、工具链改进与新能力

1) http 插件(httpPlugin)

简介:提供统一的请求封装,支持 TypeScript、Vue3、组合式 API,插件化、全局配置、请求/响应拦截器、请求元信息类型(toast/loading 灵活控制),开箱即用,便于在项目中进行全局化网络管理。。

示例:基本请求

import { http } from 'uview-pro'  

// GET  
http.get('/api/user', { id: 1 }).then(res => {  
  /* ... */  
})  

// POST  
http.post('/api/login', { username: 'xx', password: 'xx' }).then(res => {  
  /* ... */  
})  

// PUT/DELETE  
http.put('/api/user/1', { name: 'new' })  
http.delete('/api/user/1')

高级:支持请求拦截器、全局错误处理与 meta 配置,适合接入鉴权、重试、限流等策略。

最佳实践:定义拦截器配置 => 注册拦截器 => 统一 API 管理

定义拦截器配置

import type { RequestConfig, RequestInterceptor, RequestMeta, RequestOptions } from 'uview-pro'  
import { useUserStore } from '@/store'  

// 全局请求配置  
export const httpRequestConfig: RequestConfig = {  
  baseUrl,  
  header: {  
    'content-type': 'application/json'  
  },  
  meta: {  
    originalData: true,  
    toast: true,  
    loading: true  
  }  
}  

// 全局请求/响应拦截器  
export const httpInterceptor: RequestInterceptor = {  
  request: (config: RequestOptions) => {  
    // 请求拦截  
    return config  
  },  
  response: (response: any) => {  
    // 响应拦截  
    return response.data  
  }  
}

注册拦截器:

import { createSSRApp } from 'vue'  
import uViewPro, { httpPlugin } from 'uview-pro'  
import { httpInterceptor, httpRequestConfig } from 'http.interceptor'  

export function createApp() {  
  const app = createSSRApp(App)  

  // 注册uView-pro  
  app.use(uViewPro)  

  // 注册http插件  
  app.use(httpPlugin, {  
    interceptor: httpInterceptor,  
    requestConfig: httpRequestConfig  
  })  

  return { app }  
}

统一 API 管理

// api/index.ts  
import { http } from 'uview-pro'  

export const login = data => http.post('/api/login', data,  { meta: { loading: true, toast: true } })  
export const getUser = id => http.get('/api/user', { id },  { meta: { loading: false } })

以上示例为经典最佳实践,更多用法请查看 http 插件文档:https://uviewpro.cn/zh/tools/http.html

2) useCompRelation(组件关系管理 Hooks)

目的:替代传统的 provide/inject 在多平台(尤其是一些小程序宿主)可能存在的兼容问题,提供更可靠的父子组件连接和事件广播机制。

应用场景:复杂表单、级联菜单、带有子项动态增删的组件集合等。

父组件示例(伪代码):

import { useParent } from 'uview-pro';  

const { children, broadcast } = useParent('u-dropdown');  

// 广播调用子组件函数  
broadcast('childFunctionName', { payload });  

// 收集所有子组件指定值  
function getChildrenValues() {  
    let values: any[] = [];  
    children.forEach((child: any) => {  
        if (child.getExposed?.()?.isChecked.value) {  
            values.push(child.getExposed?.()?.name);  
        }  
    });  
}

子组件示例(伪代码):

const { parentExposed, emitToParent } = useChildren('u-dropdown-item', 'u-dropdown');  

// 触发父组件的函数  
emitToParent('parentFunctionName');  

// 获取父组件的变量  
const activeColor = computed(() => parentExposed.value?.activeColor);

更多用法请参考组件源码:useCompRelation.ts

3) 提供 llms.txt

llms.txt的作用是什么,一般它用来告诉大模型是否允许抓取网站数据用于训练的文件,类似于 robots.txt 控制爬虫权限,因此 uView Pro 也提供了即时更新的 llms.txt 文件,便于训练大模型,更好的为我们服务,链接如下:

https://uviewpro.cn/llms.txt

https://uviewpro.cn/llms-full.txt

四、多脚手架支持

1) create-uni

create-uni 提供一键生成、模板丰富的项目引导能力,旨在增强 uni-app 系列产品的开发体验,官网:https://uni-helper.cn/create-uni/core

pnpm create uni <项目名称> --ts -m pinia -m unocss -u uview-pro -e

表示:

  • 启用 TypeScript
  • 集成 ESLint 代码规范
  • 启用 pinia
  • 集成 unocss
  • 选择 uview-pro组件库

如果你想用 create-uni 交互式创建一个项目,请执行以下命令:

pnpm create uni

进入交互式选择界面,选择 uView Pro 模板或组件,其他的相关插件可按需选择:

使用 create-uni 快速创建 uView Pro Starter 启动模板,请执行以下命令:

pnpm create uni <项目名称> -t uview-pro-starter

使用 create-uni 快速创建 uView Pro 完整组件演示模板,请执行以下命令:

pnpm create uni <项目名称> -t uview-pro-demo

2) unibest

unibest 是目前最火的 uni-app 脚手架,它是菲鸽大佬联同众多 uni-app 开发者共同贡献的 uni-app 框架,集成了最新技术栈和开发工具,官网:https://unibest.tech/

如果你想用 unibest 和 uView Pro 来创建项目,请执行以下命令:

一行代码创建项目:

pnpm create unibest <项目名称> -t base-uview-pro

交互式创建项目:

pnpm create unibest

选择 base-uview-pro 模板:

3) 官方cli

第一种:创建以 javascript 开发的工程

npx degit dcloudio/uni-preset-vue#vite my-vue3-project

第二种:创建以 typescript 开发的工程

npx degit dcloudio/uni-preset-vue#vite-ts my-vue3-project

引入uview—pro组件库即可,不再过多介绍,可参考快速配置:https://uviewpro.cn/zh/components/setting.html

五、近期修复若干关键问题

  • u-circle-progress 的 canvas 渲染问题已修复,解决了微信小程序 canvas 2D 在不同平台上下文差异导致的绘制异常。
  • u-form 相关多个修复:处理 model 替换导致校验失效、resetFields 修复、u-form-item 样式与光标问题修复,提升表单在小程序端兼容性。
  • picker、index-list、popup 等组件的跨端兼容修复,减少在头条/支付宝/微信等宿主上的差异表现。

这些修复的综合效果是:在多端使用 uView‑Pro 构建页面时,出现的平台差异与边缘 bug 大幅减少,开发成本降低。

六、跨平台支持说明

当前 uView‑Pro 已兼容并在以下平台进行适配与测试:

  • 鸿蒙(HarmonyOS)
  • Android(原生应用及 WebView)
  • iOS(原生应用及 WebView)
  • 微信小程序
  • 支付宝小程序
  • 头条小程序

后续仍然会对多端小程序兼容性的持续投入,很多修复直接针对宿主差异展开(例如 Canvas 行为、provide/inject 实现差异、样式差异等)。

近期在鸿蒙6.0系统上运行uView Pro源码,效果还不错,如下:

七、未来计划

根据规划,未来几个方向包括:

  • 持续优化现有组件,新增组件,提升用户体验
  • 国际化(i18n)支持:统一组件的语言切换能力,方便多语言产品线接入。
  • 暗黑模式(Dark Mode):与运行时主题切换能力结合,提供暗色皮肤一键切换体验。
  • 优化现有平台兼容性,扩展更多平台的适配测试(保持对小程序宿主的兼容修复)。
  • uni-app x 支持:目前还在调研中。
  • mcp 支持。

八、结语

如果你在项目中使用到以上组件或工具,并希望参与贡献,请参考仓库的贡献指南。欢迎提 issue、提交 PR,或在插件市场与社区中反馈使用体验。

收起阅读 »

【鸿蒙征文】uView Pro 开源组件库!80+ Vue3 组件,uni-app 组件库新晋之星

鸿蒙征文

uView Pro 开源地址:

一、项目背景

uni-app 作为一款优秀的跨平台框架,凭借其“一套代码,多端运行”的理念,受到了广大移动端开发者的青睐。

而在 uni-app 的生态中,uView UI 作为一款基于 Vue2 开发的开源组件库,凭借其丰富的组件、完善的文档和良好的社区氛围,成为了许多开发者的首选,这当中就包括我,我在 2019 年接触 uni-app,刚开始只有官方的 uni-ui,没有别的选择,后来 uView UI 发布,以其简洁的 API 设计和良好的文档,成为我后来使用 uni-app 的首选,一直到现在。

然而,随着 Vue3 的正式发布以及 TypeScript 的广泛应用,越来越多的项目开始向 Vue3 技术栈迁移,大家对于兼容 Vue3 的组件库需求日益增长。然而直至现在,uView 官方也没出 Vue3 版本,这可能是精力不足的缘故。

作为一名前端开发者,相信大家都能深刻体会到 Vue3 带来的性能提升和开发体验优化,uView UI 没有进行 Vue3 迭代,无法满足新项目基于 Vue3 的开发需求。

为此,我决定用最新的技术栈 —— Vue3 + TypeScript + <script setup>,对 uView UI 进行全面重构,打造一款真正适配 uni-app Vue3 开发者的高质量组件库,并将其命名为“uView Pro”。

目前,uView Pro 已经支持:安卓、苹果、鸿蒙等App平台,h5平台,微信、支付宝、头条、QQ、钉钉等小程序平台,未来也会持续兼容其他平台,详情可查看官网:https://uviewpro.cn/

二、为什么选择 uView 1.x

我为什么选择 uView 1.x 来进行重构?而不是选择 uView 2.0

对比 1.xuView2.0uView1.x 最大的不同就是对 nvue 的支持,因为 2.x 立项的首要目标就是对 nvue 的兼容,目前 uView2.0 也全面实现了兼容 nvue

然而,我在之前的项目中对 nvue 的开发需求并不高,所以这一点对我没什么吸引力。其次,uview 2.0 对一些组件有一些优化,比如:form 表单校验的加强,优化 popup 弹窗组件 等等,如下:

uView 2.0 对比 1.X 有哪些更改?

其实还好,1.0 版本已经比较稳定了,2.0 我都没用过,所以我也没有必要重构一个不熟悉的框架。

因此,我最终选择基于 uView UI 1.8.8 的版本进行的 Vue3 重构,1.8.8uView UI 1.x 的一个最新的稳定版本,我在众多的项目中都用过,兼容性好,主要是我很熟悉源码。

uView UI 虽然不兼容 Vue3,但也能保持周活 2.6K+

市面上也有一些开发者将 uView UI 做了适配,使其兼容到 Vue3,但观其源码,大都还是使用的 Vue2Option API 风格,而我要的是 Composition API<script setup> 语法糖。

三、已完成组件重构

uView Pro 致力于覆盖 uni-app 项目开发中的各类场景,组件设计参考了 uView UI 1.8.8 的 API,确保开发者可以无缝切换。以下为已完成的 80+ 组件分类及简介:

1. 基础组件

  • Color 色彩:统一色彩体系,支持主题切换。
  • Icon 图标:丰富的图标库,支持自定义。
  • Image 图片:图片懒加载、错误占位等功能。
  • Button 按钮:多样化按钮样式,支持加载、禁用等状态。
  • Layout 布局:灵活的栅格系统,适配多端。
  • Cell 单元格:列表项展示,支持左滑操作。
  • Badge 徽标数:数字、点状等多种徽标样式。
  • Tag 标签:多样化标签样式,支持自定义颜色。

2. 表单组件

  • Form 表单:表单校验、分组、布局。
  • Calendar 日历:日期选择、范围选择。
  • Select 列选择器:多级联动选择。
  • Keyboard 键盘:自定义数字键盘。
  • Picker 选择器:多类型选择器。
  • Rate 评分:星级评分。
  • Search 搜索:搜索框,支持联想。
  • NumberBox 步进器:数字加减。
  • Upload 上传:图片、文件上传。
  • VerificationCode 验证码倒计时:短信验证码场景。
  • Field 输入框:多类型输入框。
  • Checkbox 复选框:多选项。
  • Radio 单选框:单选项。
  • Switch 开关选择器:状态切换。
  • Slider 滑动选择器:滑块选择。

3. 数据组件

  • Progress 进度条:线性、圆形进度。
  • Table 表格:多功能表格。
  • CountDown 倒计时:活动倒计时。
  • CountTo 数字滚动:数字动画。

4. 反馈组件

  • ActionSheet 操作菜单:底部弹出菜单。
  • AlertTips 警告提示:警告、提示信息。
  • Toast 消息提示:轻量弹窗。
  • NoticeBar 滚动通知:顶部公告。
  • TopTips 顶部提示:页面顶部提示。
  • SwipeAction 滑动单元格:列表项滑动操作。
  • Collapse 折叠面板:内容收起展开。
  • Popup 弹出层:多种弹窗样式。
  • Modal 模态框:确认、取消弹窗。
  • FullScreen 压窗屏:全屏弹窗。

5. 布局组件

  • Line 线条:分割线、装饰线。
  • Card 卡片:内容卡片。
  • Mask 遮罩层:遮罩效果。
  • NoNetwork 无网络提示:断网提示。
  • Grid 宫格布局:九宫格、自由布局。
  • Swiper 轮播图:图片轮播。
  • TimeLine 时间轴:事件流程展示。
  • Skeleton 骨架屏:页面加载占位。
  • Sticky 吸顶:元素吸顶。
  • Waterfall 瀑布流:图片流式布局。
  • Divider 分割线:内容分隔。

6. 导航组件

  • Dropdown 下拉菜单:多级菜单。
  • Tabbar 底部导航栏:多端适配。
  • BackTop 返回顶部:一键回顶。
  • Navbar 导航栏:页面头部导航。
  • Tabs 标签:选项卡切换。
  • TabsSwiper 全屏选项卡:滑动切换。
  • Subsection 分段器:内容分段。
  • IndexList 索引列表:字母索引。
  • Steps 步骤条:流程步骤。
  • Empty 内容为空:空状态展示。
  • Section 查看更多:内容展开。

7. 其他组件

  • MessageInput 验证码输入:短信验证码输入框。
  • Loadmore 加载更多:列表加载。
  • ReadMore 展开阅读更多:内容展开。
  • LazyLoad 懒加载:图片、内容懒加载。
  • Gap 间隔槽:布局间隔。
  • Avatar 头像:用户头像。
  • Link 超链接:文本链接。
  • Loading 加载动画:多种加载效果。

所有组件均已通过 h5、微信小程序、Android 平台的自测,最大限度的保证了良好的兼容性和稳定性。

四、技术优势与要点

1. 最新技术栈

  • Vue3 + TypeScript + <script setup>:充分利用 Vue3 的响应式、组合式 API,TypeScript 强类型保障,<script setup> 简化代码结构。
  • 全量组件重构:所有组件均基于最新技术栈重写,非简单兼容,真正适配 Vue3。
  • API 设计对齐 uView 1.8.8:最大程度降低迁移成本,老用户可无缝切换。

2. 多端兼容

  • 支持 h5、微信小程序、Android:核心组件已在主流平台自测通过,兼容性强。
  • 未来规划更多平台:后续将适配 iOS、支付宝小程序、百度小程序等。

3. 性能优化

  • 按需加载:支持 tree-shaking,减少包体积。
  • 响应式渲染:充分利用 Vue3 的响应式系统,提升渲染性能。
  • 自定义主题:支持主题切换,满足多样化需求。

4. 开发体验

  • 文档体系:同步重构文档,涵盖组件用法、API、案例。
  • VSCode 代码提示:计划开发 VSCode 插件,提升开发效率。
  • 社区支持:我创建了相关交流群、GitHub/Gitee Issues,及时响应反馈。

五、快速使用

安装

npm 安装

# npm 安装  
npm install uview-pro  

# yarn 安装  
yarn add uview-pro  

# pnpm 安装  
pnpm add uview-pro

插件市场下载

https://ext.dcloud.net.cn/plugin?id=24633

快速上手

  1. main.ts引入 uView 库
// main.ts  
import { createSSRApp } from 'vue'  
import uViewPro from 'uview-pro'  

export function createApp() {  
  const app = createSSRApp(App)  
  app.use(uViewPro)  
  // 其他配置  
  return {  
    app  
  }  
}
  1. App.vue引入基础样式(注意 style 标签需声明 scss 属性支持)
/* App.vue */  
<style lang="scss">  
@import "uview-pro/index.scss";  
</style>
  1. uni.scss引入全局 scss 变量文件
/* uni.scss */  
@import 'uview-pro/theme.scss';
  1. pages.json配置 easycom 规则(按需引入)
// pages.json  
{  
  "easycom": {  
    "autoscan": true,  
    "custom": {  
      // npm 方式  
      "^u-(.*)": "uview-pro/components/u-$1/u-$1.vue",  
      // uni_modules 方式  
      // "^u-(.*)": "@/uni_modules/uview-pro/components/u-$1/u-$1.vue"  
    }  
  },  
  "pages": [  
    // ...  
  ]  
}  

使用方法

配置 easycom 规则后,自动按需引入,无需import组件,直接引用即可。

<template>  
  <u-button>按钮</u-button>  
</template>

六、未来计划

uView Pro 的目标是成为 uni-app Vue3 生态的标杆组件库,根据规划,未来几个方向包括:

  • 持续优化现有组件,新增组件,提升用户体验
  • 国际化(i18n)支持:统一组件的语言切换能力,方便多语言产品线接入。
  • 暗黑模式(Dark Mode):与运行时主题切换能力结合,提供暗色皮肤一键切换体验。
  • 优化现有平台兼容性,扩展更多平台的适配测试(保持对小程序宿主的兼容修复)。
  • uni-app x 支持:目前还在调研中。
  • mcp 支持。

相信这一切都不会太远,期待 ing

七、结语

uView Pro 和 uView 一样,作为一款完全开源、免费商用的组件库,离不开社区的支持与贡献。无论你是前端开发者、设计师、产品经理,还是企业用户,都欢迎加入 uView Pro 的研发,参与组件开发、文档完善、生态建设等工作。所有贡献者都将在官网、文档中鸣谢。

未来,uView Pro 将持续迭代,拥抱新技术,服务更多开发者。让我们一起为 uni-app Vue3 生态贡献力量,打造更优秀的 UI 组件库!


uView Pro 开源地址:

欢迎 Star、Fork、PR、Issue,欢迎来撩!

如有问题或建议,欢迎在 Issue 区留言交流,或入群交流反馈!

继续阅读 »

uView Pro 开源地址:

一、项目背景

uni-app 作为一款优秀的跨平台框架,凭借其“一套代码,多端运行”的理念,受到了广大移动端开发者的青睐。

而在 uni-app 的生态中,uView UI 作为一款基于 Vue2 开发的开源组件库,凭借其丰富的组件、完善的文档和良好的社区氛围,成为了许多开发者的首选,这当中就包括我,我在 2019 年接触 uni-app,刚开始只有官方的 uni-ui,没有别的选择,后来 uView UI 发布,以其简洁的 API 设计和良好的文档,成为我后来使用 uni-app 的首选,一直到现在。

然而,随着 Vue3 的正式发布以及 TypeScript 的广泛应用,越来越多的项目开始向 Vue3 技术栈迁移,大家对于兼容 Vue3 的组件库需求日益增长。然而直至现在,uView 官方也没出 Vue3 版本,这可能是精力不足的缘故。

作为一名前端开发者,相信大家都能深刻体会到 Vue3 带来的性能提升和开发体验优化,uView UI 没有进行 Vue3 迭代,无法满足新项目基于 Vue3 的开发需求。

为此,我决定用最新的技术栈 —— Vue3 + TypeScript + <script setup>,对 uView UI 进行全面重构,打造一款真正适配 uni-app Vue3 开发者的高质量组件库,并将其命名为“uView Pro”。

目前,uView Pro 已经支持:安卓、苹果、鸿蒙等App平台,h5平台,微信、支付宝、头条、QQ、钉钉等小程序平台,未来也会持续兼容其他平台,详情可查看官网:https://uviewpro.cn/

二、为什么选择 uView 1.x

我为什么选择 uView 1.x 来进行重构?而不是选择 uView 2.0

对比 1.xuView2.0uView1.x 最大的不同就是对 nvue 的支持,因为 2.x 立项的首要目标就是对 nvue 的兼容,目前 uView2.0 也全面实现了兼容 nvue

然而,我在之前的项目中对 nvue 的开发需求并不高,所以这一点对我没什么吸引力。其次,uview 2.0 对一些组件有一些优化,比如:form 表单校验的加强,优化 popup 弹窗组件 等等,如下:

uView 2.0 对比 1.X 有哪些更改?

其实还好,1.0 版本已经比较稳定了,2.0 我都没用过,所以我也没有必要重构一个不熟悉的框架。

因此,我最终选择基于 uView UI 1.8.8 的版本进行的 Vue3 重构,1.8.8uView UI 1.x 的一个最新的稳定版本,我在众多的项目中都用过,兼容性好,主要是我很熟悉源码。

uView UI 虽然不兼容 Vue3,但也能保持周活 2.6K+

市面上也有一些开发者将 uView UI 做了适配,使其兼容到 Vue3,但观其源码,大都还是使用的 Vue2Option API 风格,而我要的是 Composition API<script setup> 语法糖。

三、已完成组件重构

uView Pro 致力于覆盖 uni-app 项目开发中的各类场景,组件设计参考了 uView UI 1.8.8 的 API,确保开发者可以无缝切换。以下为已完成的 80+ 组件分类及简介:

1. 基础组件

  • Color 色彩:统一色彩体系,支持主题切换。
  • Icon 图标:丰富的图标库,支持自定义。
  • Image 图片:图片懒加载、错误占位等功能。
  • Button 按钮:多样化按钮样式,支持加载、禁用等状态。
  • Layout 布局:灵活的栅格系统,适配多端。
  • Cell 单元格:列表项展示,支持左滑操作。
  • Badge 徽标数:数字、点状等多种徽标样式。
  • Tag 标签:多样化标签样式,支持自定义颜色。

2. 表单组件

  • Form 表单:表单校验、分组、布局。
  • Calendar 日历:日期选择、范围选择。
  • Select 列选择器:多级联动选择。
  • Keyboard 键盘:自定义数字键盘。
  • Picker 选择器:多类型选择器。
  • Rate 评分:星级评分。
  • Search 搜索:搜索框,支持联想。
  • NumberBox 步进器:数字加减。
  • Upload 上传:图片、文件上传。
  • VerificationCode 验证码倒计时:短信验证码场景。
  • Field 输入框:多类型输入框。
  • Checkbox 复选框:多选项。
  • Radio 单选框:单选项。
  • Switch 开关选择器:状态切换。
  • Slider 滑动选择器:滑块选择。

3. 数据组件

  • Progress 进度条:线性、圆形进度。
  • Table 表格:多功能表格。
  • CountDown 倒计时:活动倒计时。
  • CountTo 数字滚动:数字动画。

4. 反馈组件

  • ActionSheet 操作菜单:底部弹出菜单。
  • AlertTips 警告提示:警告、提示信息。
  • Toast 消息提示:轻量弹窗。
  • NoticeBar 滚动通知:顶部公告。
  • TopTips 顶部提示:页面顶部提示。
  • SwipeAction 滑动单元格:列表项滑动操作。
  • Collapse 折叠面板:内容收起展开。
  • Popup 弹出层:多种弹窗样式。
  • Modal 模态框:确认、取消弹窗。
  • FullScreen 压窗屏:全屏弹窗。

5. 布局组件

  • Line 线条:分割线、装饰线。
  • Card 卡片:内容卡片。
  • Mask 遮罩层:遮罩效果。
  • NoNetwork 无网络提示:断网提示。
  • Grid 宫格布局:九宫格、自由布局。
  • Swiper 轮播图:图片轮播。
  • TimeLine 时间轴:事件流程展示。
  • Skeleton 骨架屏:页面加载占位。
  • Sticky 吸顶:元素吸顶。
  • Waterfall 瀑布流:图片流式布局。
  • Divider 分割线:内容分隔。

6. 导航组件

  • Dropdown 下拉菜单:多级菜单。
  • Tabbar 底部导航栏:多端适配。
  • BackTop 返回顶部:一键回顶。
  • Navbar 导航栏:页面头部导航。
  • Tabs 标签:选项卡切换。
  • TabsSwiper 全屏选项卡:滑动切换。
  • Subsection 分段器:内容分段。
  • IndexList 索引列表:字母索引。
  • Steps 步骤条:流程步骤。
  • Empty 内容为空:空状态展示。
  • Section 查看更多:内容展开。

7. 其他组件

  • MessageInput 验证码输入:短信验证码输入框。
  • Loadmore 加载更多:列表加载。
  • ReadMore 展开阅读更多:内容展开。
  • LazyLoad 懒加载:图片、内容懒加载。
  • Gap 间隔槽:布局间隔。
  • Avatar 头像:用户头像。
  • Link 超链接:文本链接。
  • Loading 加载动画:多种加载效果。

所有组件均已通过 h5、微信小程序、Android 平台的自测,最大限度的保证了良好的兼容性和稳定性。

四、技术优势与要点

1. 最新技术栈

  • Vue3 + TypeScript + <script setup>:充分利用 Vue3 的响应式、组合式 API,TypeScript 强类型保障,<script setup> 简化代码结构。
  • 全量组件重构:所有组件均基于最新技术栈重写,非简单兼容,真正适配 Vue3。
  • API 设计对齐 uView 1.8.8:最大程度降低迁移成本,老用户可无缝切换。

2. 多端兼容

  • 支持 h5、微信小程序、Android:核心组件已在主流平台自测通过,兼容性强。
  • 未来规划更多平台:后续将适配 iOS、支付宝小程序、百度小程序等。

3. 性能优化

  • 按需加载:支持 tree-shaking,减少包体积。
  • 响应式渲染:充分利用 Vue3 的响应式系统,提升渲染性能。
  • 自定义主题:支持主题切换,满足多样化需求。

4. 开发体验

  • 文档体系:同步重构文档,涵盖组件用法、API、案例。
  • VSCode 代码提示:计划开发 VSCode 插件,提升开发效率。
  • 社区支持:我创建了相关交流群、GitHub/Gitee Issues,及时响应反馈。

五、快速使用

安装

npm 安装

# npm 安装  
npm install uview-pro  

# yarn 安装  
yarn add uview-pro  

# pnpm 安装  
pnpm add uview-pro

插件市场下载

https://ext.dcloud.net.cn/plugin?id=24633

快速上手

  1. main.ts引入 uView 库
// main.ts  
import { createSSRApp } from 'vue'  
import uViewPro from 'uview-pro'  

export function createApp() {  
  const app = createSSRApp(App)  
  app.use(uViewPro)  
  // 其他配置  
  return {  
    app  
  }  
}
  1. App.vue引入基础样式(注意 style 标签需声明 scss 属性支持)
/* App.vue */  
<style lang="scss">  
@import "uview-pro/index.scss";  
</style>
  1. uni.scss引入全局 scss 变量文件
/* uni.scss */  
@import 'uview-pro/theme.scss';
  1. pages.json配置 easycom 规则(按需引入)
// pages.json  
{  
  "easycom": {  
    "autoscan": true,  
    "custom": {  
      // npm 方式  
      "^u-(.*)": "uview-pro/components/u-$1/u-$1.vue",  
      // uni_modules 方式  
      // "^u-(.*)": "@/uni_modules/uview-pro/components/u-$1/u-$1.vue"  
    }  
  },  
  "pages": [  
    // ...  
  ]  
}  

使用方法

配置 easycom 规则后,自动按需引入,无需import组件,直接引用即可。

<template>  
  <u-button>按钮</u-button>  
</template>

六、未来计划

uView Pro 的目标是成为 uni-app Vue3 生态的标杆组件库,根据规划,未来几个方向包括:

  • 持续优化现有组件,新增组件,提升用户体验
  • 国际化(i18n)支持:统一组件的语言切换能力,方便多语言产品线接入。
  • 暗黑模式(Dark Mode):与运行时主题切换能力结合,提供暗色皮肤一键切换体验。
  • 优化现有平台兼容性,扩展更多平台的适配测试(保持对小程序宿主的兼容修复)。
  • uni-app x 支持:目前还在调研中。
  • mcp 支持。

相信这一切都不会太远,期待 ing

七、结语

uView Pro 和 uView 一样,作为一款完全开源、免费商用的组件库,离不开社区的支持与贡献。无论你是前端开发者、设计师、产品经理,还是企业用户,都欢迎加入 uView Pro 的研发,参与组件开发、文档完善、生态建设等工作。所有贡献者都将在官网、文档中鸣谢。

未来,uView Pro 将持续迭代,拥抱新技术,服务更多开发者。让我们一起为 uni-app Vue3 生态贡献力量,打造更优秀的 UI 组件库!


uView Pro 开源地址:

欢迎 Star、Fork、PR、Issue,欢迎来撩!

如有问题或建议,欢迎在 Issue 区留言交流,或入群交流反馈!

收起阅读 »

【鸿蒙征文】当新手遇上HarmonyOS:打造一款“垃圾分类助手”元服务

鸿蒙征文

作为一名刚刚踏入鸿蒙生态的个人开发者,面对HarmonyOS丰富的开放能力,既兴奋又忐忑。我选择开发一款轻量化的“智能垃圾分类助手”元服务作为我的第一个项目,它能让用户通过相机或输入快速查询垃圾类别,并通过卡片直观展示分类结果。本文将重点分享我如何集成三项关键的鸿蒙开放能力,并成功解决开发中的实际问题。

一、能力集成背景:解决“不知道是什么垃圾”的痛点

在日常生活的垃圾分类场景中,用户常常面临“不确定垃圾类别”、“查询步骤繁琐”以及“需要快速获取结果”的核心痛点
。传统的解决方案要么需要用户安装独立的APP占用存储空间,要么查询路径较长,体验不够流畅。
目标:开发一款即用即走的元服务,通过卡片直接提供服务,实现“随手拍、随手查、结果立现”。
挑战:作为新手,我需要一个低后端维护成本、能快速实现核心功能(如图像识别或智能问答)、并能提供流畅前端交互的方案。
鸿蒙能力选型:针对以上,我选择了以下三项鸿蒙开放能力,它们极大地降低了我的开发门槛:
端云一体化开发(云开发):提供Serverless后端支持,我不必自建服务器。
元服务卡片:作为服务的直接入口,实现服务外显,减少操作层级。
AI能力(结合ArkUI):计划整合智能识别或自然语言处理功能,用于垃圾识别。

二、集成的鸿蒙开放能力全称及核心功能

端云一体化开发(云开发):

核心功能:允许开发者在DevEco Studio内一站式完成端侧(应用)和云侧(云函数、云数据库)的开发。云函数用于处理复杂的业务逻辑(如调用AI模型进行图像识别或智能问答),云数据库用于存储垃圾分类规则数据。这让我无需关注服务器运维,只需专注业务逻辑实现

元服务卡片:

核心功能:元服务是HarmonyOS的一种新型服务提供方式,以万能卡片等多种形态呈现,具有即用即走、信息外显的特性
。我的垃圾分类结果可以直接展示在卡片上,用户无需进入完整应用。

AI大模型能力(结合ArkUI框架):

核心功能:HarmonyOS提供了丰富的AI接口。在本项目中,我探索了如何利用ArkUI声明式开发范式
构建交互界面,并初步尝试集成鸿蒙的AI能力(例如通过云函数调用大模型API),实现对用户输入的文本(如“香蕉皮”)进行智能识别和分类。

三、能力集成的核心步骤

3.1 端云一体化开发(云开发)集成

在DevEco Studio中创建新项目时,我选择了“端云一体化开发”模板。IDE自动为我创建了端侧(Application)和云侧(CloudProgram)的工程结构

关键步骤1:创建云函数。在CloudProgram/cloudfunctions目录下,我右键新建了一个名为classifyGarbage的云函数。这个函数的核心逻辑是接收用户上传的图片URL或文本,调用AI服务进行识别,并从云数据库中查询对应的分类结果。
关键步骤2:部署与调试。编写完云函数后,通过DevEco Studio的“Deploy Cloud Functions”功能,将其部署到AGC(AppGallery Connect)平台。利用“Cloud Functions Requestor”工具在本地进行模拟触发和调试,确保函数逻辑正确

关键步骤3:端侧调用。在元服务的ETS代码中,引入AGConnect的云函数SDK,并通过简单的API调用云函数。

// 示例代码片段 (基于 ArkTS)  
import agconnect from '@hw-agconnect/api-ohos';  
import "@hw-agconnect/function-ohos";  

async function classifyWaste(inputText: string) {  
  try {  
    const result = await agconnect.function().wrap("classifyGarbage").call({  
      input: inputText  
    });  
    console.log("Classification result:", result.getValue());  
    // 更新UI或卡片信息  
  } catch (error) {  
    console.error("Error calling cloud function:", error);  
  }  
}

3.2 元服务卡片开发

关键步骤1:卡片布局。在entry/src/main/ets/entryformability目录下,定义卡片的UI布局。我使用了ArkUI的组件,如Text、Image和Button,来构建一个包含输入框、查询按钮和结果展示区域的卡片界面

关键步骤2:动态数据更新。利用ArkUI的声明式UI和状态管理(如@State装饰器),当从云函数获取到分类结果后,自动更新卡片上显示的文本内容,实现数据的动态绑定

3.3 AI能力探索与ArkUI界面构建

作为新手,我首先从相对成熟的文本交互入手。我构建了一个简单的文本输入界面,并将用户输入传递给云函数。在云函数内,可以集成预训练的模型或调用第三方AI服务API来完成智能分类。
关键步骤:在UI中,使用TextInput组件接收用户输入,使用Button组件触发查询事件,并将结果显示在Text组件中
。这体现了ArkUI框架声明式开发的高效性。

四、场景落地

4.1 应用场景落地

该“智能垃圾分类助手”元服务典型的使用场景是:用户在需要丢弃垃圾时,直接桌面上滑找到服务卡片,输入垃圾名称(如“过期药品”),点击查询,卡片上即刻显示出分类结果(如“有害垃圾”)。整个过程无需下载安装大型应用,体验非常轻量化。

五、总结与展望

通过这个小小的项目,我深刻体会到HarmonyOS开放能力为个人开发者带来的强大赋能。端云一体化开发让我这个新手也能轻松拥有“云端大脑”;元服务卡片让我的应用以最优雅的方式触达用户;而ArkUI与AI能力的结合则让智能交互变得简单可行。
对于所有和我一样的个人新手开发者,我的建议是:从一个小痛点出发,勇敢地利用鸿蒙提供的强大工具箱,你会发现,创新和实现,离我们并不遥远。​ 鸿蒙广阔的生态,正等待我们共同描绘。

继续阅读 »

作为一名刚刚踏入鸿蒙生态的个人开发者,面对HarmonyOS丰富的开放能力,既兴奋又忐忑。我选择开发一款轻量化的“智能垃圾分类助手”元服务作为我的第一个项目,它能让用户通过相机或输入快速查询垃圾类别,并通过卡片直观展示分类结果。本文将重点分享我如何集成三项关键的鸿蒙开放能力,并成功解决开发中的实际问题。

一、能力集成背景:解决“不知道是什么垃圾”的痛点

在日常生活的垃圾分类场景中,用户常常面临“不确定垃圾类别”、“查询步骤繁琐”以及“需要快速获取结果”的核心痛点
。传统的解决方案要么需要用户安装独立的APP占用存储空间,要么查询路径较长,体验不够流畅。
目标:开发一款即用即走的元服务,通过卡片直接提供服务,实现“随手拍、随手查、结果立现”。
挑战:作为新手,我需要一个低后端维护成本、能快速实现核心功能(如图像识别或智能问答)、并能提供流畅前端交互的方案。
鸿蒙能力选型:针对以上,我选择了以下三项鸿蒙开放能力,它们极大地降低了我的开发门槛:
端云一体化开发(云开发):提供Serverless后端支持,我不必自建服务器。
元服务卡片:作为服务的直接入口,实现服务外显,减少操作层级。
AI能力(结合ArkUI):计划整合智能识别或自然语言处理功能,用于垃圾识别。

二、集成的鸿蒙开放能力全称及核心功能

端云一体化开发(云开发):

核心功能:允许开发者在DevEco Studio内一站式完成端侧(应用)和云侧(云函数、云数据库)的开发。云函数用于处理复杂的业务逻辑(如调用AI模型进行图像识别或智能问答),云数据库用于存储垃圾分类规则数据。这让我无需关注服务器运维,只需专注业务逻辑实现

元服务卡片:

核心功能:元服务是HarmonyOS的一种新型服务提供方式,以万能卡片等多种形态呈现,具有即用即走、信息外显的特性
。我的垃圾分类结果可以直接展示在卡片上,用户无需进入完整应用。

AI大模型能力(结合ArkUI框架):

核心功能:HarmonyOS提供了丰富的AI接口。在本项目中,我探索了如何利用ArkUI声明式开发范式
构建交互界面,并初步尝试集成鸿蒙的AI能力(例如通过云函数调用大模型API),实现对用户输入的文本(如“香蕉皮”)进行智能识别和分类。

三、能力集成的核心步骤

3.1 端云一体化开发(云开发)集成

在DevEco Studio中创建新项目时,我选择了“端云一体化开发”模板。IDE自动为我创建了端侧(Application)和云侧(CloudProgram)的工程结构

关键步骤1:创建云函数。在CloudProgram/cloudfunctions目录下,我右键新建了一个名为classifyGarbage的云函数。这个函数的核心逻辑是接收用户上传的图片URL或文本,调用AI服务进行识别,并从云数据库中查询对应的分类结果。
关键步骤2:部署与调试。编写完云函数后,通过DevEco Studio的“Deploy Cloud Functions”功能,将其部署到AGC(AppGallery Connect)平台。利用“Cloud Functions Requestor”工具在本地进行模拟触发和调试,确保函数逻辑正确

关键步骤3:端侧调用。在元服务的ETS代码中,引入AGConnect的云函数SDK,并通过简单的API调用云函数。

// 示例代码片段 (基于 ArkTS)  
import agconnect from '@hw-agconnect/api-ohos';  
import "@hw-agconnect/function-ohos";  

async function classifyWaste(inputText: string) {  
  try {  
    const result = await agconnect.function().wrap("classifyGarbage").call({  
      input: inputText  
    });  
    console.log("Classification result:", result.getValue());  
    // 更新UI或卡片信息  
  } catch (error) {  
    console.error("Error calling cloud function:", error);  
  }  
}

3.2 元服务卡片开发

关键步骤1:卡片布局。在entry/src/main/ets/entryformability目录下,定义卡片的UI布局。我使用了ArkUI的组件,如Text、Image和Button,来构建一个包含输入框、查询按钮和结果展示区域的卡片界面

关键步骤2:动态数据更新。利用ArkUI的声明式UI和状态管理(如@State装饰器),当从云函数获取到分类结果后,自动更新卡片上显示的文本内容,实现数据的动态绑定

3.3 AI能力探索与ArkUI界面构建

作为新手,我首先从相对成熟的文本交互入手。我构建了一个简单的文本输入界面,并将用户输入传递给云函数。在云函数内,可以集成预训练的模型或调用第三方AI服务API来完成智能分类。
关键步骤:在UI中,使用TextInput组件接收用户输入,使用Button组件触发查询事件,并将结果显示在Text组件中
。这体现了ArkUI框架声明式开发的高效性。

四、场景落地

4.1 应用场景落地

该“智能垃圾分类助手”元服务典型的使用场景是:用户在需要丢弃垃圾时,直接桌面上滑找到服务卡片,输入垃圾名称(如“过期药品”),点击查询,卡片上即刻显示出分类结果(如“有害垃圾”)。整个过程无需下载安装大型应用,体验非常轻量化。

五、总结与展望

通过这个小小的项目,我深刻体会到HarmonyOS开放能力为个人开发者带来的强大赋能。端云一体化开发让我这个新手也能轻松拥有“云端大脑”;元服务卡片让我的应用以最优雅的方式触达用户;而ArkUI与AI能力的结合则让智能交互变得简单可行。
对于所有和我一样的个人新手开发者,我的建议是:从一个小痛点出发,勇敢地利用鸿蒙提供的强大工具箱,你会发现,创新和实现,离我们并不遥远。​ 鸿蒙广阔的生态,正等待我们共同描绘。

收起阅读 »

集成鸿蒙五大核心能力,打造高性能生活服务类元服务

鸿蒙征文

集成鸿蒙五大核心能力,打造高性能生活服务类元服务

一、能力集成背景

在生活服务类元服务开发过程中,我们面临三大核心痛点:一是启动速度慢,首次打开需加载大量资源,用户等待时长超3秒,流失率达28%;二是跨端跳转体验割裂,从社交平台分享链接打开元服务时,常出现页面断层、参数丢失问题;三是崩溃率居高不下(峰值达1.2%),且难以精准定位根因;四是测试环境复杂,多设备兼容性测试效率低,回归测试成本高;五是用户行为数据零散,无法针对性优化核心功能。

为解决上述问题,我们深度集成鸿蒙五大开放能力——预加载、AppLinking、APMS、云测试、应用分析,通过技术协同实现全链路体验优化,最终达成性能与用户体验的双重提升。

二、核心能力集成关键步骤

2.1 预加载能力:提升启动速度

  1. 配置预加载规则:在config.json5中声明预加载组件与触发条件,指定当用户点击系统桌面“生活服务”文件夹时,提前加载元服务核心页面(首页、服务列表页):
    "preload": {  
    "triggerConditions": ["folderClick"],  
    "targetComponents": ["MainAbility", "ServiceListAbility"],  
    "preloadDelay": 1000  
    }
  2. 资源优先级优化:通过ohos:preloadPriority属性设置资源加载顺序,优先加载页面骨架屏与核心交互组件,非关键资源延迟加载。
  3. 内存占用控制:监听系统内存状态,当设备内存低于2GB时,自动关闭预加载任务,避免资源竞争。

2.2 AppLinking:实现跨端无缝跳转

  1. 在AppGallery Connect后台创建AppLinking链接,配置跳转路径模板(如https://xxx.hmsclouddra.com/link/{path}),绑定元服务包名与页面路由。
  2. 客户端集成解析逻辑:在AbilityStageonAcceptWant方法中,解析AppLinking携带的参数(如服务ID、用户偏好),直接跳转至目标页面:
    @Override  
    public void onAcceptWant(Want want) {  
    String deepLink = want.getStringParam(AppLinkingConstants.DEEP_LINK);  
    if (deepLink != null) {  
    Uri uri = Uri.parse(deepLink);  
    String serviceId = uri.getQueryParameter("serviceId");  
    router.pushUrl("pages/ServiceDetail/ServiceDetail", new RouterOptions().withParam("serviceId", serviceId));  
    }  
    }
  3. 异常处理:添加链接有效性校验与降级策略,当链接过期或参数错误时,自动跳转至首页并给出友好提示。

2.3 APMS能力:精准定位崩溃问题

  1. 初始化APMS SDK:在应用启动时调用APMS.getInstance().init(),开启崩溃监控与性能数据采集。
  2. 自定义崩溃上报:通过setCrashListener监听崩溃事件,补充业务上下文(如用户操作路径、接口请求参数),上报至自定义日志平台:
    APMS.getInstance().setCrashListener((crashInfo) -> {  
    CrashReport report = new CrashReport();  
    report.setCrashInfo(crashInfo);  
    report.setUserActionPath(UserBehavior.getInstance().getActionPath());  
    report.setRequestParams(NetworkManager.getInstance().getLastRequestParams());  
    ReportManager.upload(report);  
    });
  3. 性能阈值告警:设置主线程卡顿阈值(500ms)与内存泄漏监测规则,当触发阈值时,自动采集线程栈与内存快照。

2.4 云测试:提升测试效率

  1. 创建云测试任务:在AppGallery Connect控制台选择“兼容性测试”模板,勾选HarmonyOS 3.0及以上版本的主流机型(覆盖15款终端,含手机、平板、折叠屏)。
  2. 上传测试用例:通过云测试API导入UI自动化测试脚本(基于ArkUI组件定位),设置测试场景(如服务预约、订单提交)与断言条件。
  3. 自动化回归:配置每次代码提交后触发云测试任务,生成多设备兼容性报告与性能测试数据(启动时间、帧率),支持一键查看失败用例录屏。

2.5 应用分析:数据驱动优化

  1. 自定义事件埋点:通过AnalyticsHelper上报核心业务事件(如服务点击、订单提交、页面停留时长),携带关键维度(如服务类型、用户年龄段):
    AnalyticsHelper.getInstance().recordEvent("service_click", new HashMap<String, String>() {{  
    put("service_type", "food_delivery");  
    put("click_position", "homepage_card");  
    }});
  2. 配置指标看板:在应用分析后台创建核心指标看板,实时监控启动速度、页面跳转成功率、用户留存率等关键数据。
  3. 异常数据预警:设置指标阈值(如启动时间>2秒触发预警),通过企业微信推送异常数据,快速响应性能波动。

三、场景落地与量化效果

3.1 核心应用场景

该元服务聚焦本地生活服务(餐饮外卖、生鲜配送、家政服务),覆盖用户“发现-预约-使用-评价”全流程,鸿蒙五大能力贯穿从启动到留存的完整链路:用户通过社交平台分享的AppLinking链接打开元服务(AppLinking),预加载能力已提前备好核心页面(预加载),使用过程中APMS实时监控崩溃风险(APMS),云测试保障多设备使用流畅(云测试),应用分析持续追踪用户行为(应用分析),形成闭环优化体系。

3.2 量化效果对比

优化指标 集成前 集成后 提升幅度
首次启动时间(冷启动) 3.2秒 1.5秒 降低53.1%
跨端跳转成功率 89.3% 99.7% 提升10.4个百分点
应用崩溃率(日均) 1.2% 0.3% 降低75%
兼容性测试周期 3个工作日 4小时 缩短94.4%
核心功能用户留存率(7日) 45.2% 58.7% 提升13.5个百分点

关键场景运行截图如下:

  1. 预加载触发效果:用户点击文件夹后,元服务1.5秒内完成启动,骨架屏快速渲染(附启动过程录屏片段,链接:xxx);
  2. AppLinking跳转效果:从微信分享链接点击后,直接跳转至餐饮服务详情页,参数传递准确(附跳转流程截图);
  3. APMS崩溃定位:通过APMS获取的线程栈信息,成功定位3起因JSON解析异常导致的崩溃,根因为参数类型不匹配(附崩溃分析报告截图)。

四、总结

本次通过集成鸿蒙预加载、AppLinking、APMS、云测试、应用分析五大核心能力,成功解决了生活服务类元服务的性能与体验痛点。核心收获有三:一是技术层面,鸿蒙开放能力的模块化设计降低了集成门槛,不同能力可灵活组合适配业务场景;二是效率层面,云测试与APMS的协同使用,将问题排查周期从“天级”缩短至“小时级”;三是业务层面,量化数据驱动产品迭代,核心指标的显著提升直接带动用户留存与活跃度增长。

后续我们将进一步探索鸿蒙近场能力与云开发服务的集成,实现“设备联动+云端协同”的创新场景,持续为用户提供更智能、高效的本地生活服务。

继续阅读 »

集成鸿蒙五大核心能力,打造高性能生活服务类元服务

一、能力集成背景

在生活服务类元服务开发过程中,我们面临三大核心痛点:一是启动速度慢,首次打开需加载大量资源,用户等待时长超3秒,流失率达28%;二是跨端跳转体验割裂,从社交平台分享链接打开元服务时,常出现页面断层、参数丢失问题;三是崩溃率居高不下(峰值达1.2%),且难以精准定位根因;四是测试环境复杂,多设备兼容性测试效率低,回归测试成本高;五是用户行为数据零散,无法针对性优化核心功能。

为解决上述问题,我们深度集成鸿蒙五大开放能力——预加载、AppLinking、APMS、云测试、应用分析,通过技术协同实现全链路体验优化,最终达成性能与用户体验的双重提升。

二、核心能力集成关键步骤

2.1 预加载能力:提升启动速度

  1. 配置预加载规则:在config.json5中声明预加载组件与触发条件,指定当用户点击系统桌面“生活服务”文件夹时,提前加载元服务核心页面(首页、服务列表页):
    "preload": {  
    "triggerConditions": ["folderClick"],  
    "targetComponents": ["MainAbility", "ServiceListAbility"],  
    "preloadDelay": 1000  
    }
  2. 资源优先级优化:通过ohos:preloadPriority属性设置资源加载顺序,优先加载页面骨架屏与核心交互组件,非关键资源延迟加载。
  3. 内存占用控制:监听系统内存状态,当设备内存低于2GB时,自动关闭预加载任务,避免资源竞争。

2.2 AppLinking:实现跨端无缝跳转

  1. 在AppGallery Connect后台创建AppLinking链接,配置跳转路径模板(如https://xxx.hmsclouddra.com/link/{path}),绑定元服务包名与页面路由。
  2. 客户端集成解析逻辑:在AbilityStageonAcceptWant方法中,解析AppLinking携带的参数(如服务ID、用户偏好),直接跳转至目标页面:
    @Override  
    public void onAcceptWant(Want want) {  
    String deepLink = want.getStringParam(AppLinkingConstants.DEEP_LINK);  
    if (deepLink != null) {  
    Uri uri = Uri.parse(deepLink);  
    String serviceId = uri.getQueryParameter("serviceId");  
    router.pushUrl("pages/ServiceDetail/ServiceDetail", new RouterOptions().withParam("serviceId", serviceId));  
    }  
    }
  3. 异常处理:添加链接有效性校验与降级策略,当链接过期或参数错误时,自动跳转至首页并给出友好提示。

2.3 APMS能力:精准定位崩溃问题

  1. 初始化APMS SDK:在应用启动时调用APMS.getInstance().init(),开启崩溃监控与性能数据采集。
  2. 自定义崩溃上报:通过setCrashListener监听崩溃事件,补充业务上下文(如用户操作路径、接口请求参数),上报至自定义日志平台:
    APMS.getInstance().setCrashListener((crashInfo) -> {  
    CrashReport report = new CrashReport();  
    report.setCrashInfo(crashInfo);  
    report.setUserActionPath(UserBehavior.getInstance().getActionPath());  
    report.setRequestParams(NetworkManager.getInstance().getLastRequestParams());  
    ReportManager.upload(report);  
    });
  3. 性能阈值告警:设置主线程卡顿阈值(500ms)与内存泄漏监测规则,当触发阈值时,自动采集线程栈与内存快照。

2.4 云测试:提升测试效率

  1. 创建云测试任务:在AppGallery Connect控制台选择“兼容性测试”模板,勾选HarmonyOS 3.0及以上版本的主流机型(覆盖15款终端,含手机、平板、折叠屏)。
  2. 上传测试用例:通过云测试API导入UI自动化测试脚本(基于ArkUI组件定位),设置测试场景(如服务预约、订单提交)与断言条件。
  3. 自动化回归:配置每次代码提交后触发云测试任务,生成多设备兼容性报告与性能测试数据(启动时间、帧率),支持一键查看失败用例录屏。

2.5 应用分析:数据驱动优化

  1. 自定义事件埋点:通过AnalyticsHelper上报核心业务事件(如服务点击、订单提交、页面停留时长),携带关键维度(如服务类型、用户年龄段):
    AnalyticsHelper.getInstance().recordEvent("service_click", new HashMap<String, String>() {{  
    put("service_type", "food_delivery");  
    put("click_position", "homepage_card");  
    }});
  2. 配置指标看板:在应用分析后台创建核心指标看板,实时监控启动速度、页面跳转成功率、用户留存率等关键数据。
  3. 异常数据预警:设置指标阈值(如启动时间>2秒触发预警),通过企业微信推送异常数据,快速响应性能波动。

三、场景落地与量化效果

3.1 核心应用场景

该元服务聚焦本地生活服务(餐饮外卖、生鲜配送、家政服务),覆盖用户“发现-预约-使用-评价”全流程,鸿蒙五大能力贯穿从启动到留存的完整链路:用户通过社交平台分享的AppLinking链接打开元服务(AppLinking),预加载能力已提前备好核心页面(预加载),使用过程中APMS实时监控崩溃风险(APMS),云测试保障多设备使用流畅(云测试),应用分析持续追踪用户行为(应用分析),形成闭环优化体系。

3.2 量化效果对比

优化指标 集成前 集成后 提升幅度
首次启动时间(冷启动) 3.2秒 1.5秒 降低53.1%
跨端跳转成功率 89.3% 99.7% 提升10.4个百分点
应用崩溃率(日均) 1.2% 0.3% 降低75%
兼容性测试周期 3个工作日 4小时 缩短94.4%
核心功能用户留存率(7日) 45.2% 58.7% 提升13.5个百分点

关键场景运行截图如下:

  1. 预加载触发效果:用户点击文件夹后,元服务1.5秒内完成启动,骨架屏快速渲染(附启动过程录屏片段,链接:xxx);
  2. AppLinking跳转效果:从微信分享链接点击后,直接跳转至餐饮服务详情页,参数传递准确(附跳转流程截图);
  3. APMS崩溃定位:通过APMS获取的线程栈信息,成功定位3起因JSON解析异常导致的崩溃,根因为参数类型不匹配(附崩溃分析报告截图)。

四、总结

本次通过集成鸿蒙预加载、AppLinking、APMS、云测试、应用分析五大核心能力,成功解决了生活服务类元服务的性能与体验痛点。核心收获有三:一是技术层面,鸿蒙开放能力的模块化设计降低了集成门槛,不同能力可灵活组合适配业务场景;二是效率层面,云测试与APMS的协同使用,将问题排查周期从“天级”缩短至“小时级”;三是业务层面,量化数据驱动产品迭代,核心指标的显著提升直接带动用户留存与活跃度增长。

后续我们将进一步探索鸿蒙近场能力与云开发服务的集成,实现“设备联动+云端协同”的创新场景,持续为用户提供更智能、高效的本地生活服务。

收起阅读 »

【鸿蒙征文】uni-app 鸿蒙开发实践:华为账号一键登录集成之路

鸿蒙next 鸿蒙征文

完整示例截图

注意获取手机号需要企业账号才可以申请这个权限,个人帐号需要使用静默登陆获取 openid 进行登陆

前言

随着鸿蒙生态的快速发展,越来越多的开发者开始尝试将应用迁移到鸿蒙平台。作为一名 uni-app 开发者,我在将应用适配鸿蒙的过程中,遇到了用户登录这一基础但重要的需求。华为账号一键登录作为鸿蒙生态的重要开放能力,能够为用户提供便捷、安全的登录体验。但在实际集成过程中,我却经历了从迷茫到豁然开朗的曲折历程。

本文将分享我在 uni-app 项目中集成华为账号一键登录能力的完整过程,特别是如何获取用户真实手机号这个核心难题,包括走过的弯路、问题的根源、以及最终的自主实现方案。

一、需求场景与功能特点

应用场景

在开发鸿蒙应用时,用户登录是最基础也是最关键的功能。传统的登录方式存在诸多痛点:

  1. 短信验证码登录

    • 需要用户手动输入手机号
    • 等待验证码到达,体验不流畅
    • 可能遇到验证码延迟或收不到的问题
  2. 账号密码登录

    • 用户需要记忆密码
    • 首次使用需要注册流程
    • 密码找回流程复杂
  3. 第三方登录

    • 需要跳转第三方应用
    • 授权流程较长
    • 部分用户不信任第三方授权

而华为账号一键登录,完美解决了这些痛点:

核心应用场景

  • 📱 电商应用:快速注册登录,降低用户流失率
  • 🎮 游戏应用:一键登录游戏,快速进入游戏体验
  • 📰 内容平台:简化登录流程,提升内容消费体验
  • 💼 企业应用:安全可靠的身份认证
  • 🏥 生活服务:快速获取用户手机号,便于服务通知

功能特点

anhao-login 插件提供了完整的华为账号登录能力:

✅ 获取用户唯一标识

  • unionID:用户在开发者账号下的唯一标识,跨应用一致
  • openID:用户在当前应用的唯一标识
  • 用途:用户身份识别、账号绑定、数据关联

✅ 获取用户基础信息

  • 头像:用户的华为账号头像
  • 昵称:用户的华为账号昵称
  • 用途:丰富用户资料,提升社交体验

✅ 快速获取手机号(核心功能)

  • 匿名手机号:脱敏显示(如:131******23),无需授权
  • 真实手机号:通过一键登录组件获取,需用户授权
  • 用途:用户注册、身份验证、服务通知

✅ 一键授权,体验流畅

  • 用户点击一次按钮即可完成授权
  • 无需手动输入任何信息
  • 整个流程 2-3 秒完成

✅ 安全可靠

  • 基于华为账号体系,安全等级高
  • 符合隐私保护规范
  • 授权码单次使用,防止重放攻击

✅ 易于集成

  • API 简洁友好
  • 详细的文档和示例
  • 完整的服务端集成说明

二、错误的尝试:走了一个月的弯路

最初的方案:使用 createAuthorizationWithHuaweiIDRequest

一个月前,我开始尝试集成华为账号一键登录功能。根据华为官方文档,我找到了账号授权的 API:createAuthorizationWithHuaweiIDRequest

文档中提到,可以通过设置 scopes 参数来申请不同的权限。我看到有一个 phone scope,心想:"这就是获取手机号的权限!"

于是,我开始了第一次尝试:

// 错误的方案(仅适用于游戏应用)  
const loginRequest = new authentication.HuaweiIDProvider().createAuthorizationWithHuaweiIDRequest();  
        loginRequest.forceAuthorization = true;  
        loginRequest.scopes = ["phone"];  
        loginRequest.permissions = ["serviceauthcode"];  
        loginRequest.state = util.generateRandomUUID();  
        loginRequest.nonce = util.generateRandomUUID();  
        loginRequest.idTokenSignAlgorithm = authentication.IdTokenSignAlgorithm.PS256;  

        const context = getContext() as common.UIAbilityContext;  
        const controller = new authentication.AuthenticationController(context);  

        controller.executeRequest(loginRequest, (error : BusinessError<Object>, data) => {  
            if (error) {  
                hilog.error(0x0000, 'HuaweiLogin', `Failed to login with profile: ${JSON.stringify(error)}`);  
                return;  
            }  
        });  
    } catch (error) {  
        hilog.error(0x0000, 'HuaweiLogin', `Exception in hmLoginWithProfile: ${error}`);  
    }

持续一个月的"没有权限"错误

但是,无论我如何尝试,调用这个 API 时总是返回"没有权限"的错误。我反复检查了所有配置:

AppGallery Connect 配置

  • 应用已正确创建
  • 应用信息已完善
  • SHA256 指纹已正确配置

开放能力申请

  • 已申请"华为账号一键登录"能力
  • 审核状态:已通过

客户端配置

  • client_id 已正确配置到 module.json5
  • 应用签名与平台配置一致
  • 代码中的 API 调用看起来没问题

权限配置

  • manifest.json 中已添加必要权限
  • 鸿蒙应用权限已正确声明

所有配置看起来都没有问题,但就是无法获取手机号权限。这个问题困扰了我整整一个月。

尝试过的各种方法

在这一个月里,我尝试了各种可能的解决方案:

  1. 重新申请开放能力:以为是审核有问题,重新申请了好几次
  2. 更换测试设备:换了不同的鸿蒙设备测试
  3. 重新生成签名:以为是签名配置问题
  4. 查阅官方文档:把账号服务相关文档翻了好几遍
  5. 搜索开发者论坛:看了很多类似问题的讨论
  6. 参考其他项目:找了一些开源项目的代码参考

但是,所有的尝试都以失败告终。我开始怀疑:是不是华为的这个 API 在 uni-app 环境下就是不可用?

三、峰回路转:华为技术支持揭示真相

就在我几乎要放弃的时候,我决定直接联系华为的技术支持团队。非常幸运的是,华为技术老师非常热心,专门为我安排了一次线上技术交流会议。

问题的真相:phone scope 仅限游戏应用

在会议中,华为技术老师一针见血地指出了问题所在:

phone scope 仅适用于游戏类应用,普通应用无法通过 createAuthorizationWithHuaweiIDRequest 获取手机号权限!

这句话如同醍醐灌顶,一下子解开了困扰我一个月的谜团。原来:

  1. 权限级别不同

    • 游戏应用:可以通过 phone scope 直接获取手机号
    • 普通应用:不能使用 phone scope
  2. 设计原因

    • 华为为了保护用户隐私,对不同类型应用设置了不同的权限级别
    • 手机号属于高度敏感的个人信息,需要更严格的授权流程
  3. 文档说明不够明确

    • 官方文档中对 phone scope 的使用限制说明不够突出
    • 容易让开发者误以为所有应用都可以使用

正确的方案:使用华为内置登录组件

华为技术老师告诉我,普通应用要获取用户手机号,必须使用华为提供的专用登录组件:

  1. loginComponentManager:华为提供的登录组件管理器
  2. LoginWithHuaweiIDButton:华为官方的登录按钮 UI 组件

通过这两个组件获取的授权码(authorizationCode),才能在服务端调用华为接口换取真实的用户手机号。

关键点

  • 必须使用华为提供的 UI 组件
  • 用户必须能够清楚地看到授权内容
  • 用户必须主动点击授权按钮
  • 通过组件获取的授权码才有获取手机号的权限
  • 可以同时获取用户的头像和昵称

这样的设计确保了用户的知情权和选择权,但也增加了开发的复杂度。

四、新的挑战:uni-app 如何调用鸿蒙原生组件?

得知了正确的方案后,我面临一个新的问题:华为技术老师没有提供 uni-app 的集成方案。

华为官方文档中的示例都是基于原生鸿蒙开发的,使用的是 ArkTS 语言。而我们的项目是 uni-app 框架,如何在 uni-app 中调用鸿蒙原生组件呢?

自主探索:研究 DCloud 官方文档

既然没有现成的方案,我只能自己探索。我开始研究 DCloud 官方文档,找到了关键的一篇文档:

uni-app 调用鸿蒙原生组件

这篇文档详细介绍了如何在 uni-app 中通过 <embed> 标签调用鸿蒙原生组件。关键要点:

  1. 使用 <embed> 标签:uni-app 提供的特殊标签,用于嵌入原生组件
  2. tag 属性:指定原生组件的标识
  3. options 属性:传递给原生组件的配置参数
  4. 事件监听:通过 @success@fail 等监听原生组件的事件

实现方案:封装华为登录组件

基于 DCloud 的文档和示例,我开始着手实现 uni-app 调用华为登录组件的方案。

1. 原生侧实现(ArkTS)

uni_modules/anhao-login/utssdk/app-harmony/login.ets 中实现登录组件的封装:

import { defineNativeEmbed, NativeEmbedBuilderOptions } from '@dcloudio/uni-app-runtime'  
import { authentication, loginComponentManager, LoginWithHuaweiIDButton } from '@kit.AccountKit'  
import { hilog } from '@kit.PerformanceAnalysisKit'  
import { BusinessError } from '@kit.BasicServicesKit'  

// 定义参数接口  
interface QuickLoginOptions extends NativeEmbedBuilderOptions {  
}  

// 定义返回数据接口  
interface QuickLoginSuccessDetail {  
  authorizationCode?: string  
  unionID?: string  
  openID?: string  
  success: boolean  
  err: string  
}  

interface QickLoginEvent {  
  type: string  
  detail: QuickLoginSuccessDetail  
}  

// 定义登录组件  
@Component  
struct QuickLoginComponent {  
  onSuccess?: Function  
  onFail?: Function  

  // 创建登录组件控制器  
  private controller: loginComponentManager.LoginWithHuaweiIDButtonController =  
    new loginComponentManager.LoginWithHuaweiIDButtonController()  
      // 设置协议状态为已同意(实际应用中可根据需求调整)  
      .setAgreementStatus(loginComponentManager.AgreementStatus.ACCEPTED)  
      // 设置点击登录按钮的回调  
      .onClickLoginWithHuaweiIDButton((error: BusinessError, response: loginComponentManager.HuaweiIDCredential) => {  
        if (error) {  
          // 登录失败  
          hilog.error(0x0000, 'QuickLogin', `failed: ${error.code} ${error.message}`)  
          if (this.onFail) {  
            const detail = {  
              success: false,  
              err: `failed: ${error.code} ${error.message}`  
            } as QuickLoginSuccessDetail  
            const res = {  
              type: "quickLogin",  
              detail: detail  
            } as QickLoginEvent  
            this.onFail(res)  
          }  
        } else {  
          // 登录成功  
          hilog.info(0x0000, 'QuickLogin', `success: ${response.authorizationCode}`)  
          if (this.onSuccess) {  
            const detail = {  
              authorizationCode: response.authorizationCode,  // 授权码  
              unionID: response.unionID,                      // 用户统一ID  
              openID: response.openID,                        // 应用内用户ID  
              success: true,  
              err: 'ok',  
            } as QuickLoginSuccessDetail  
            const res = {  
              type: "quickLogin",  
              detail: detail  
            } as QickLoginEvent  
            this.onSuccess(res)  
          }  
        }  
      })  
      .onClickEvent((error: BusinessError, clickEvent: loginComponentManager.ClickEvent) => {  
        hilog.info(0x0000, 'testTag', `QuickLogin clickEvent: ${clickEvent}`);  
      });  

  build() {  
    // 创建华为登录按钮  
    LoginWithHuaweiIDButton({  
      params: {  
        style: loginComponentManager.Style.BUTTON_CUSTOM,         // 按钮样式  
        loginType: loginComponentManager.LoginType.QUICK_LOGIN,   // 登录类型:一键登录  
        supportDarkMode: true,                                     // 支持深色模式  
      },  
      controller: this.controller  
    })  
      .width('100%')  
      .height('100%')  
  }  
}  

// 定义构建器  
@Builder  
function QuickLoginBuilder(opts: QuickLoginOptions) {  
  QuickLoginComponent({  
    onSuccess: opts?.on?.get('success'),  
    onFail: opts?.on?.get('fail')  
  })  
    .width(opts.width)  
    .height(opts.height)  
}  

// 注册原生组件,标识为 'hwilogin'  
defineNativeEmbed('hwilogin', { builder: QuickLoginBuilder })

关键技术点

  1. 使用 defineNativeEmbed:这是 DCloud 提供的 API,用于注册原生组件
  2. loginComponentManager.LoginWithHuaweiIDButtonController:华为提供的登录控制器
  3. LoginWithHuaweiIDButton:华为官方的登录按钮组件
  4. loginType: QUICK_LOGIN:设置为一键登录类型,可以获取手机号
  5. onClickLoginWithHuaweiIDButton:登录成功后的回调,返回授权码、unionID、openID
  6. 事件传递:通过 onSuccessonFail 将结果传递给 uni-app

2. uni-app 侧调用

在 uni-app 中,通过 <embed> 标签调用原生组件:

<template>  
  <view class="container">  
    <!-- 使用 embed 标签嵌入华为登录按钮 -->  
    <embed  
      class="login-button"  
      tag="hwilogin"  
      :options="options"  
      @success="loginSuccess"  
      @fail="loginFail"  
    ></embed>  

    <view class="user-info" v-if="userInfo.phone">  
      <text>手机号:{{ userInfo.phone }}</text>  
      <text>unionID:{{ userInfo.unionID }}</text>  
    </view>  
  </view>  
</template>  

<script setup>  
import { ref } from 'vue'  
import '@/uni_modules/anhao-login'  

const options = ref({})  
const userInfo = ref({  
  phone: '',  
  unionID: '',  
  openID: ''  
})  

const loginSuccess = ({ detail }) => {  
  console.log('登录成功:', detail)  

  // detail 结构:  
  // {  
  //   authorizationCode: "xxx", // 授权码(关键!)  
  //   unionID: "xxx",          // 用户统一ID  
  //   openID: "xxx",           // 应用内用户ID  
  //   success: true,  
  //   err: "ok"  
  // }  

  userInfo.value.unionID = detail.unionID  
  userInfo.value.openID = detail.openID  

  // 将授权码发送到服务端,换取真实手机号  
  getPhoneFromServer(detail.authorizationCode)  
}  

const loginFail = (err) => {  
  console.error('登录失败:', err)  
  uni.showToast({  
    title: '登录失败,请重试',  
    icon: 'none'  
  })  
}  

// 调用服务端接口获取真实手机号  
const getPhoneFromServer = async (code) => {  
  try {  
    const res = await uni.request({  
      url: 'https://your-server.com/api/getPhoneNumber',  
      method: 'POST',  
      data: {  
        code: code  
      }  
    })  

    if (res.data.success) {  
      userInfo.value.phone = res.data.phoneNumber  
      uni.showToast({  
        title: '登录成功',  
        icon: 'success'  
      })  
    }  
  } catch (error) {  
    console.error('获取手机号失败:', error)  
    uni.showToast({  
      title: '获取手机号失败',  
      icon: 'none'  
    })  
  }  
}  
</script>  

<style scoped>  
.container {  
  padding: 100px 20px;  
}  
.login-button {  
  display: block;  
  width: 200px;  
  height: 50px;  
  margin: 10px auto;  
}  
.user-info {  
  margin-top: 40px;  
  text-align: center;  
}  
</style>

技术难点与解决方案

在实现过程中,我遇到了一些技术难点:

难点 1:原生组件的生命周期管理

问题:鸿蒙原生组件有自己的生命周期,如何与 uni-app 的页面生命周期同步?

解决方案

  • 使用 @Component 装饰器定义组件
  • build() 方法中创建 UI
  • 通过 defineNativeEmbed 注册后,uni-app 会自动管理组件生命周期

难点 2:事件通信机制

问题:原生组件的事件如何传递到 uni-app 侧?

解决方案

  • 在原生侧,通过 onSuccessonFail 回调函数传递事件
  • 使用 opts?.on?.get('success') 获取 uni-app 传递的事件监听器
  • uni-app 侧通过 @success@fail 监听事件

难点 3:参数传递

问题:uni-app 如何向原生组件传递参数?

解决方案

  • 通过 :options 属性传递配置参数
  • 原生侧通过 NativeEmbedBuilderOptions 接口接收参数
  • 支持动态更新参数(通过响应式数据)

难点 4:组件样式定制

问题:华为登录按钮的样式如何定制?

解决方案

  • 使用 loginComponentManager.Style.BUTTON_CUSTOM 自定义样式
  • 通过 .width().height() 设置组件尺寸
  • 可以在外层包裹自定义样式

五、服务端集成:获取真实手机号

客户端获取授权码后,还需要服务端配合才能获取真实手机号。这是整个流程中最关键的一步。

华为服务端接口

接口地址

POST https://account-api.cloud.huawei.com/oauth2/v6/quickLogin/getPhoneNumber

请求头

Content-Type: application/json;charset=UTF-8

请求参数

{  
  "code": "<authorizationCode>",  
  "clientId": "<your-clientId>",  
  "clientSecret": "<your-clientSecret>"  
}
参数名 类型 必填 说明
code String 客户端通过登录组件获取的授权码
clientId String 应用的 clientId
clientSecret String 应用的 clientSecret(必须保密!)

响应示例

{  
  "openId": "xxxx",  
  "unionId": "xxxx",  
  "phoneNumber": "13111111111",  
  "phoneNumberValid": 1,  
  "purePhoneNumber": "13111111111",  
  "phoneCountryCode": "0086"  
}

服务端实现示例

方案一:PHP 实现

<?php  
header('Content-Type: application/json;charset=UTF-8');  

// 获取客户端传递的授权码  
$requestData = json_decode(file_get_contents('php://input'), true);  
$code = $requestData['code'] ?? '';  

// 验证授权码  
if (empty($code)) {  
    http_response_code(400);  
    echo json_encode([  
        'success' => false,  
        'message' => '缺少授权码'  
    ]);  
    exit;  
}  

// 华为 API 配置(从环境变量或配置文件读取)  
$clientId = getenv('HUAWEI_CLIENT_ID');  
$clientSecret = getenv('HUAWEI_CLIENT_SECRET');  

// 调用华为接口获取手机号  
$url = 'https://account-api.cloud.huawei.com/oauth2/v6/quickLogin/getPhoneNumber';  
$postData = json_encode([  
    'code' => $code,  
    'clientId' => $clientId,  
    'clientSecret' => $clientSecret  
]);  

$ch = curl_init();  
curl_setopt($ch, CURLOPT_URL, $url);  
curl_setopt($ch, CURLOPT_POST, 1);  
curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);  
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);  
curl_setopt($ch, CURLOPT_HTTPHEADER, [  
    'Content-Type: application/json;charset=UTF-8'  
]);  

$response = curl_exec($ch);  
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);  
curl_close($ch);  

if ($httpCode == 200) {  
    $result = json_decode($response, true);  

    // 返回手机号给客户端  
    echo json_encode([  
        'success' => true,  
        'phoneNumber' => $result['phoneNumber'],  
        'phoneCountryCode' => $result['phoneCountryCode'],  
        'unionId' => $result['unionId'],  
        'openId' => $result['openId']  
    ]);  
} else {  
    http_response_code(500);  
    echo json_encode([  
        'success' => false,  
        'message' => '获取手机号失败',  
        'error' => $response  
    ]);  
}  
?>

安全注意事项

⚠️ 非常重要

  1. clientSecret 必须保存在服务端

    • 绝对不能写在客户端代码中
    • 应该使用环境变量或密钥管理服务
    • 定期更换 clientSecret
  2. 授权码验证

    • 授权码只能使用一次
    • 授权码有效期很短(通常几分钟)
    • 服务端应记录已使用的授权码,防止重放攻击
  3. 防刷机制

    • 限制同一用户的请求频率
    • 记录异常请求日志
    • 必要时添加图形验证码
  4. HTTPS 传输

    • 所有接口必须使用 HTTPS
    • 防止授权码在传输过程中被窃取
  5. 数据存储

    • 手机号等敏感信息应加密存储
    • 遵守数据保护法规(如 GDPR、个人信息保护法)

六、完整功能实现

基于以上技术方案,插件最终实现了三个核心功能:

1. 获取匿名手机号

快速获取用户的脱敏手机号(如:131******23),无需用户额外授权:

import { getHmAnonymousPhone } from '@/uni_modules/anhao-login'  

getHmAnonymousPhone({  
  success(res) {  
    console.log('脱敏手机号:', res.quickLoginAnonymousPhone)  
    console.log('openID:', res.openID)  
    console.log('unionID:', res.unionID)  
    console.log('本机号码一致性:', res.localNumberConsistency)  
  },  
  fail(err) {  
    console.error('获取失败:', err)  
  }  
})

适用场景

  • 快速注册场景
  • 用户身份初步识别
  • 本机号码一致性检测

2. 获取用户头像昵称

通过引导用户授权,获取用户的基础信息:

import { hmLoginWithProfile } from '@/uni_modules/anhao-login'  

hmLoginWithProfile({  
  success(res) {  
    console.log('昵称:', res.nickName)  
    console.log('头像:', res.avatarUri)  
    console.log('unionID:', res.unionID)  
  },  
  fail(err) {  
    console.error('获取失败:', err)  
  }  
})

适用场景

  • 用户资料完善
  • 社交应用的用户展示
  • 个性化推荐

3. 华为账号一键登录(核心功能)

通过华为内置登录组件,获取授权码,同时可以获取用户的头像和昵称,再通过服务端接口获取真实手机号。

完整流程

用户点击登录按钮  
     ↓  
调用华为登录组件  
     ↓  
用户确认授权  
     ↓  
获取 authorizationCode、unionID、openID  
(可同时获取头像、昵称)  
     ↓  
发送授权码到服务端  
     ↓  
服务端调用华为接口  
     ↓  
获取真实手机号  
     ↓  
返回给客户端  
     ↓  
登录成功

特别说明

  • 在用户点击登录按钮授权后,除了获取 authorizationCode,还可以同时获取用户的头像昵称
  • 这样可以一次授权完成用户的完整信息获取,无需多次交互
  • 大大提升了用户体验和开发效率

七、插件封装与开源

为什么要做成插件

在解决了集成问题后,我意识到:

  1. 这个问题具有普遍性:很多 uni-app 开发者可能都会遇到同样的问题
  2. 华为登录是刚需:鸿蒙应用都需要用户登录功能
  3. 没有现成的方案:uni-app 生态中缺少成熟的华为登录插件
  4. 技术门槛较高:涉及原生开发,对普通开发者不够友好

因此,我决定将这个功能封装成标准的 uni-app 插件,并开源出来,让更多开发者能够快速集成华为账号登录功能。

开源地址

为了方便开发者使用和贡献,插件已经在多个平台开源:

欢迎大家使用、提 Issue 和 PR,共同完善这个插件!

八、开发心得与经验总结

回顾整个集成过程,我有以下几点深刻体会:

1. 不要轻易怀疑官方文档,但也不要完全依赖

华为的官方文档提供了很多有价值的信息,但对于一些特殊限制(如 phone scope 仅限游戏应用),说明可能不够突出。

经验教训

  • 遇到问题时,先仔细阅读官方文档,特别是"注意事项"和"权限说明"部分
  • 如果文档无法解决,及时联系官方技术支持
  • 多参考官方示例代码,理解推荐的实现方式

2. 理解平台设计理念很重要

华为限制 phone scope 的使用,并不是故意增加开发难度,而是为了保护用户隐私:

  • 手机号是高度敏感的个人信息
  • 用户必须清楚知道自己在授权什么
  • 通过 UI 组件授权,确保用户的知情权

经验教训

  • 理解并尊重平台的安全机制
  • 不要尝试绕过安全限制
  • 站在用户隐私保护的角度思考问题

3. 跨平台开发需要深入原生

uni-app 虽然提供了跨平台能力,但在集成平台特有功能时,仍然需要深入原生层:

  • 理解鸿蒙原生组件的机制
  • 掌握 uni-app 的原生扩展方式
  • 熟悉原生代码与 JS 的通信机制

经验教训

  • 不要指望所有功能都能通过纯 JS 实现
  • 学习平台原生开发知识是必要的
  • 善用 DCloud 提供的原生扩展机制

4. 没有现成方案就自己创造

华为技术老师没有提供 uni-app 的集成方案,但这不是放弃的理由:

  • DCloud 提供了完善的原生组件调用文档
  • 社区有很多开发者分享的经验
  • 通过学习和实践,我们也能创造自己的方案

经验教训

  • 遇到技术挑战,先思考是否有类似的解决方案可以参考
  • 善用搜索引擎和开发者社区
  • 不要害怕深入原生层,这是技术成长的必经之路

5. 安全性永远是第一位

在整个实现过程中,安全性始终是最重要的考虑因素:

  • clientSecret 必须保存在服务端
  • 授权码必须做防重放处理
  • 用户数据传输必须使用 HTTPS

经验教训

  • 永远不要将密钥写在客户端代码中
  • 敏感操作必须在服务端完成
  • 为授权码设置有效期和使用次数限制
  • 定期进行安全审计

6. 开源是最好的学习和回馈方式

在解决问题的过程中,我参考了很多开源项目和社区讨论。当我自己解决了问题后,我也选择将方案开源:

  • 帮助他人,节省他们的时间
  • 收到反馈,促进自己的成长
  • 建立口碑,融入开发者社区
  • 共同建设更好的鸿蒙生态

开源的价值

  • 知识的传播和共享
  • 技术的迭代和改进
  • 社区的繁荣和发展
  • 个人的成长和提升

7. 感恩与协作

这次集成之所以能成功,离不开很多人的帮助:

  • 华为技术老师的耐心指导,解答了关键问题
  • DCloud 提供的完善文档和示例
  • 社区开发者的经验分享和反馈

感恩

  • 感谢华为技术团队为开发者提供的支持
  • 感谢 DCloud 搭建的跨平台开发生态
  • 感谢所有为鸿蒙生态建设贡献力量的开发者

协作

  • 多参与社区讨论,分享经验
  • 遇到问题时,整理成文档帮助后来人
  • 对他人的开源项目表示支持和感谢
  • 共同推动鸿蒙生态的发展

九、写在最后

从一个月前的困惑,到今天的豁然开朗,再到最终的开源贡献,这段经历让我深刻体会到:

技术问题没有解决不了的,关键是找对方法和寻求帮助。

回顾这次经历,最大的收获不仅仅是解决了一个技术问题,更重要的是:

  1. 学会了正确的求助方式:遇到问题时,先自己尝试,实在解决不了就及时联系官方技术支持
  2. 理解了平台设计理念:安全和隐私保护永远是第一位的
  3. 掌握了原生集成能力:在 uni-app 中调用鸿蒙原生组件
  4. 建立了开源思维:用开源的方式回馈社区
  5. 感受到了协作的力量:一个人走得快,一群人走得远

鸿蒙生态正处于快速发展阶段,还有很多开放能力等待我们去探索和实践。作为 uni-app 开发者,我们既能享受跨平台开发的便利,又能深入集成各平台的原生能力。

希望这篇文章能帮助到正在或即将集成华为账号服务的开发者们。如果你在使用插件的过程中遇到任何问题,欢迎通过以下方式联系我:

让我们一起,为鸿蒙生态的繁荣贡献自己的力量!


附录:关键技术点总结

错误方案 vs 正确方案

对比项 错误方案 正确方案
API 调用 createAuthorizationWithHuaweiIDRequest loginComponentManager + LoginWithHuaweiIDButton
权限申请 scopes: ['phone'] 通过登录组件自动处理
适用范围 仅游戏应用 普通应用
UI 要求 必须使用华为提供的 UI 组件
授权码权限 无法获取手机号 可以获取手机号
获取信息 有限 可同时获取手机号、头像、昵称、unionID、openID

uni-app 调用原生组件关键点

  1. 使用 defineNativeEmbed 注册原生组件
  2. 设置 tag 属性 为原生组件标识(如 hwilogin
  3. 通过 :options 传递配置 参数
  4. 通过 @success@fail 监听事件
  5. 使用 <embed> 标签 嵌入原生组件
  6. 处理原生组件的生命周期 和事件传递

服务端集成关键点

  1. clientSecret 必须保存在服务端
  2. 授权码只能使用一次
  3. 授权码有效期很短(几分钟)
  4. 必须使用 HTTPS 传输
  5. 添加防刷机制
  6. 记录已使用的授权码,防止重放攻击
  7. 敏感数据加密存储

华为登录组件配置要点

LoginWithHuaweiIDButton({  
  params: {  
    style: loginComponentManager.Style.BUTTON_CUSTOM,         // 按钮样式  
    loginType: loginComponentManager.LoginType.QUICK_LOGIN,   // 登录类型:一键登录  
    supportDarkMode: true,                                     // 支持深色模式  
  },  
  controller: this.controller  
})

关键参数

  • style:按钮样式,可选 BUTTON_BLUEBUTTON_WHITEBUTTON_CUSTOM
  • loginType:登录类型,QUICK_LOGIN 表示一键登录,可获取手机号
  • supportDarkMode:是否支持深色模式

关于作者

一名热爱开源的 uni-app 开发者,专注于跨平台应用开发和鸿蒙生态探索。在摸索中成长,在分享中进步。

相关链接

继续阅读 »

完整示例截图

注意获取手机号需要企业账号才可以申请这个权限,个人帐号需要使用静默登陆获取 openid 进行登陆

前言

随着鸿蒙生态的快速发展,越来越多的开发者开始尝试将应用迁移到鸿蒙平台。作为一名 uni-app 开发者,我在将应用适配鸿蒙的过程中,遇到了用户登录这一基础但重要的需求。华为账号一键登录作为鸿蒙生态的重要开放能力,能够为用户提供便捷、安全的登录体验。但在实际集成过程中,我却经历了从迷茫到豁然开朗的曲折历程。

本文将分享我在 uni-app 项目中集成华为账号一键登录能力的完整过程,特别是如何获取用户真实手机号这个核心难题,包括走过的弯路、问题的根源、以及最终的自主实现方案。

一、需求场景与功能特点

应用场景

在开发鸿蒙应用时,用户登录是最基础也是最关键的功能。传统的登录方式存在诸多痛点:

  1. 短信验证码登录

    • 需要用户手动输入手机号
    • 等待验证码到达,体验不流畅
    • 可能遇到验证码延迟或收不到的问题
  2. 账号密码登录

    • 用户需要记忆密码
    • 首次使用需要注册流程
    • 密码找回流程复杂
  3. 第三方登录

    • 需要跳转第三方应用
    • 授权流程较长
    • 部分用户不信任第三方授权

而华为账号一键登录,完美解决了这些痛点:

核心应用场景

  • 📱 电商应用:快速注册登录,降低用户流失率
  • 🎮 游戏应用:一键登录游戏,快速进入游戏体验
  • 📰 内容平台:简化登录流程,提升内容消费体验
  • 💼 企业应用:安全可靠的身份认证
  • 🏥 生活服务:快速获取用户手机号,便于服务通知

功能特点

anhao-login 插件提供了完整的华为账号登录能力:

✅ 获取用户唯一标识

  • unionID:用户在开发者账号下的唯一标识,跨应用一致
  • openID:用户在当前应用的唯一标识
  • 用途:用户身份识别、账号绑定、数据关联

✅ 获取用户基础信息

  • 头像:用户的华为账号头像
  • 昵称:用户的华为账号昵称
  • 用途:丰富用户资料,提升社交体验

✅ 快速获取手机号(核心功能)

  • 匿名手机号:脱敏显示(如:131******23),无需授权
  • 真实手机号:通过一键登录组件获取,需用户授权
  • 用途:用户注册、身份验证、服务通知

✅ 一键授权,体验流畅

  • 用户点击一次按钮即可完成授权
  • 无需手动输入任何信息
  • 整个流程 2-3 秒完成

✅ 安全可靠

  • 基于华为账号体系,安全等级高
  • 符合隐私保护规范
  • 授权码单次使用,防止重放攻击

✅ 易于集成

  • API 简洁友好
  • 详细的文档和示例
  • 完整的服务端集成说明

二、错误的尝试:走了一个月的弯路

最初的方案:使用 createAuthorizationWithHuaweiIDRequest

一个月前,我开始尝试集成华为账号一键登录功能。根据华为官方文档,我找到了账号授权的 API:createAuthorizationWithHuaweiIDRequest

文档中提到,可以通过设置 scopes 参数来申请不同的权限。我看到有一个 phone scope,心想:"这就是获取手机号的权限!"

于是,我开始了第一次尝试:

// 错误的方案(仅适用于游戏应用)  
const loginRequest = new authentication.HuaweiIDProvider().createAuthorizationWithHuaweiIDRequest();  
        loginRequest.forceAuthorization = true;  
        loginRequest.scopes = ["phone"];  
        loginRequest.permissions = ["serviceauthcode"];  
        loginRequest.state = util.generateRandomUUID();  
        loginRequest.nonce = util.generateRandomUUID();  
        loginRequest.idTokenSignAlgorithm = authentication.IdTokenSignAlgorithm.PS256;  

        const context = getContext() as common.UIAbilityContext;  
        const controller = new authentication.AuthenticationController(context);  

        controller.executeRequest(loginRequest, (error : BusinessError<Object>, data) => {  
            if (error) {  
                hilog.error(0x0000, 'HuaweiLogin', `Failed to login with profile: ${JSON.stringify(error)}`);  
                return;  
            }  
        });  
    } catch (error) {  
        hilog.error(0x0000, 'HuaweiLogin', `Exception in hmLoginWithProfile: ${error}`);  
    }

持续一个月的"没有权限"错误

但是,无论我如何尝试,调用这个 API 时总是返回"没有权限"的错误。我反复检查了所有配置:

AppGallery Connect 配置

  • 应用已正确创建
  • 应用信息已完善
  • SHA256 指纹已正确配置

开放能力申请

  • 已申请"华为账号一键登录"能力
  • 审核状态:已通过

客户端配置

  • client_id 已正确配置到 module.json5
  • 应用签名与平台配置一致
  • 代码中的 API 调用看起来没问题

权限配置

  • manifest.json 中已添加必要权限
  • 鸿蒙应用权限已正确声明

所有配置看起来都没有问题,但就是无法获取手机号权限。这个问题困扰了我整整一个月。

尝试过的各种方法

在这一个月里,我尝试了各种可能的解决方案:

  1. 重新申请开放能力:以为是审核有问题,重新申请了好几次
  2. 更换测试设备:换了不同的鸿蒙设备测试
  3. 重新生成签名:以为是签名配置问题
  4. 查阅官方文档:把账号服务相关文档翻了好几遍
  5. 搜索开发者论坛:看了很多类似问题的讨论
  6. 参考其他项目:找了一些开源项目的代码参考

但是,所有的尝试都以失败告终。我开始怀疑:是不是华为的这个 API 在 uni-app 环境下就是不可用?

三、峰回路转:华为技术支持揭示真相

就在我几乎要放弃的时候,我决定直接联系华为的技术支持团队。非常幸运的是,华为技术老师非常热心,专门为我安排了一次线上技术交流会议。

问题的真相:phone scope 仅限游戏应用

在会议中,华为技术老师一针见血地指出了问题所在:

phone scope 仅适用于游戏类应用,普通应用无法通过 createAuthorizationWithHuaweiIDRequest 获取手机号权限!

这句话如同醍醐灌顶,一下子解开了困扰我一个月的谜团。原来:

  1. 权限级别不同

    • 游戏应用:可以通过 phone scope 直接获取手机号
    • 普通应用:不能使用 phone scope
  2. 设计原因

    • 华为为了保护用户隐私,对不同类型应用设置了不同的权限级别
    • 手机号属于高度敏感的个人信息,需要更严格的授权流程
  3. 文档说明不够明确

    • 官方文档中对 phone scope 的使用限制说明不够突出
    • 容易让开发者误以为所有应用都可以使用

正确的方案:使用华为内置登录组件

华为技术老师告诉我,普通应用要获取用户手机号,必须使用华为提供的专用登录组件:

  1. loginComponentManager:华为提供的登录组件管理器
  2. LoginWithHuaweiIDButton:华为官方的登录按钮 UI 组件

通过这两个组件获取的授权码(authorizationCode),才能在服务端调用华为接口换取真实的用户手机号。

关键点

  • 必须使用华为提供的 UI 组件
  • 用户必须能够清楚地看到授权内容
  • 用户必须主动点击授权按钮
  • 通过组件获取的授权码才有获取手机号的权限
  • 可以同时获取用户的头像和昵称

这样的设计确保了用户的知情权和选择权,但也增加了开发的复杂度。

四、新的挑战:uni-app 如何调用鸿蒙原生组件?

得知了正确的方案后,我面临一个新的问题:华为技术老师没有提供 uni-app 的集成方案。

华为官方文档中的示例都是基于原生鸿蒙开发的,使用的是 ArkTS 语言。而我们的项目是 uni-app 框架,如何在 uni-app 中调用鸿蒙原生组件呢?

自主探索:研究 DCloud 官方文档

既然没有现成的方案,我只能自己探索。我开始研究 DCloud 官方文档,找到了关键的一篇文档:

uni-app 调用鸿蒙原生组件

这篇文档详细介绍了如何在 uni-app 中通过 <embed> 标签调用鸿蒙原生组件。关键要点:

  1. 使用 <embed> 标签:uni-app 提供的特殊标签,用于嵌入原生组件
  2. tag 属性:指定原生组件的标识
  3. options 属性:传递给原生组件的配置参数
  4. 事件监听:通过 @success@fail 等监听原生组件的事件

实现方案:封装华为登录组件

基于 DCloud 的文档和示例,我开始着手实现 uni-app 调用华为登录组件的方案。

1. 原生侧实现(ArkTS)

uni_modules/anhao-login/utssdk/app-harmony/login.ets 中实现登录组件的封装:

import { defineNativeEmbed, NativeEmbedBuilderOptions } from '@dcloudio/uni-app-runtime'  
import { authentication, loginComponentManager, LoginWithHuaweiIDButton } from '@kit.AccountKit'  
import { hilog } from '@kit.PerformanceAnalysisKit'  
import { BusinessError } from '@kit.BasicServicesKit'  

// 定义参数接口  
interface QuickLoginOptions extends NativeEmbedBuilderOptions {  
}  

// 定义返回数据接口  
interface QuickLoginSuccessDetail {  
  authorizationCode?: string  
  unionID?: string  
  openID?: string  
  success: boolean  
  err: string  
}  

interface QickLoginEvent {  
  type: string  
  detail: QuickLoginSuccessDetail  
}  

// 定义登录组件  
@Component  
struct QuickLoginComponent {  
  onSuccess?: Function  
  onFail?: Function  

  // 创建登录组件控制器  
  private controller: loginComponentManager.LoginWithHuaweiIDButtonController =  
    new loginComponentManager.LoginWithHuaweiIDButtonController()  
      // 设置协议状态为已同意(实际应用中可根据需求调整)  
      .setAgreementStatus(loginComponentManager.AgreementStatus.ACCEPTED)  
      // 设置点击登录按钮的回调  
      .onClickLoginWithHuaweiIDButton((error: BusinessError, response: loginComponentManager.HuaweiIDCredential) => {  
        if (error) {  
          // 登录失败  
          hilog.error(0x0000, 'QuickLogin', `failed: ${error.code} ${error.message}`)  
          if (this.onFail) {  
            const detail = {  
              success: false,  
              err: `failed: ${error.code} ${error.message}`  
            } as QuickLoginSuccessDetail  
            const res = {  
              type: "quickLogin",  
              detail: detail  
            } as QickLoginEvent  
            this.onFail(res)  
          }  
        } else {  
          // 登录成功  
          hilog.info(0x0000, 'QuickLogin', `success: ${response.authorizationCode}`)  
          if (this.onSuccess) {  
            const detail = {  
              authorizationCode: response.authorizationCode,  // 授权码  
              unionID: response.unionID,                      // 用户统一ID  
              openID: response.openID,                        // 应用内用户ID  
              success: true,  
              err: 'ok',  
            } as QuickLoginSuccessDetail  
            const res = {  
              type: "quickLogin",  
              detail: detail  
            } as QickLoginEvent  
            this.onSuccess(res)  
          }  
        }  
      })  
      .onClickEvent((error: BusinessError, clickEvent: loginComponentManager.ClickEvent) => {  
        hilog.info(0x0000, 'testTag', `QuickLogin clickEvent: ${clickEvent}`);  
      });  

  build() {  
    // 创建华为登录按钮  
    LoginWithHuaweiIDButton({  
      params: {  
        style: loginComponentManager.Style.BUTTON_CUSTOM,         // 按钮样式  
        loginType: loginComponentManager.LoginType.QUICK_LOGIN,   // 登录类型:一键登录  
        supportDarkMode: true,                                     // 支持深色模式  
      },  
      controller: this.controller  
    })  
      .width('100%')  
      .height('100%')  
  }  
}  

// 定义构建器  
@Builder  
function QuickLoginBuilder(opts: QuickLoginOptions) {  
  QuickLoginComponent({  
    onSuccess: opts?.on?.get('success'),  
    onFail: opts?.on?.get('fail')  
  })  
    .width(opts.width)  
    .height(opts.height)  
}  

// 注册原生组件,标识为 'hwilogin'  
defineNativeEmbed('hwilogin', { builder: QuickLoginBuilder })

关键技术点

  1. 使用 defineNativeEmbed:这是 DCloud 提供的 API,用于注册原生组件
  2. loginComponentManager.LoginWithHuaweiIDButtonController:华为提供的登录控制器
  3. LoginWithHuaweiIDButton:华为官方的登录按钮组件
  4. loginType: QUICK_LOGIN:设置为一键登录类型,可以获取手机号
  5. onClickLoginWithHuaweiIDButton:登录成功后的回调,返回授权码、unionID、openID
  6. 事件传递:通过 onSuccessonFail 将结果传递给 uni-app

2. uni-app 侧调用

在 uni-app 中,通过 <embed> 标签调用原生组件:

<template>  
  <view class="container">  
    <!-- 使用 embed 标签嵌入华为登录按钮 -->  
    <embed  
      class="login-button"  
      tag="hwilogin"  
      :options="options"  
      @success="loginSuccess"  
      @fail="loginFail"  
    ></embed>  

    <view class="user-info" v-if="userInfo.phone">  
      <text>手机号:{{ userInfo.phone }}</text>  
      <text>unionID:{{ userInfo.unionID }}</text>  
    </view>  
  </view>  
</template>  

<script setup>  
import { ref } from 'vue'  
import '@/uni_modules/anhao-login'  

const options = ref({})  
const userInfo = ref({  
  phone: '',  
  unionID: '',  
  openID: ''  
})  

const loginSuccess = ({ detail }) => {  
  console.log('登录成功:', detail)  

  // detail 结构:  
  // {  
  //   authorizationCode: "xxx", // 授权码(关键!)  
  //   unionID: "xxx",          // 用户统一ID  
  //   openID: "xxx",           // 应用内用户ID  
  //   success: true,  
  //   err: "ok"  
  // }  

  userInfo.value.unionID = detail.unionID  
  userInfo.value.openID = detail.openID  

  // 将授权码发送到服务端,换取真实手机号  
  getPhoneFromServer(detail.authorizationCode)  
}  

const loginFail = (err) => {  
  console.error('登录失败:', err)  
  uni.showToast({  
    title: '登录失败,请重试',  
    icon: 'none'  
  })  
}  

// 调用服务端接口获取真实手机号  
const getPhoneFromServer = async (code) => {  
  try {  
    const res = await uni.request({  
      url: 'https://your-server.com/api/getPhoneNumber',  
      method: 'POST',  
      data: {  
        code: code  
      }  
    })  

    if (res.data.success) {  
      userInfo.value.phone = res.data.phoneNumber  
      uni.showToast({  
        title: '登录成功',  
        icon: 'success'  
      })  
    }  
  } catch (error) {  
    console.error('获取手机号失败:', error)  
    uni.showToast({  
      title: '获取手机号失败',  
      icon: 'none'  
    })  
  }  
}  
</script>  

<style scoped>  
.container {  
  padding: 100px 20px;  
}  
.login-button {  
  display: block;  
  width: 200px;  
  height: 50px;  
  margin: 10px auto;  
}  
.user-info {  
  margin-top: 40px;  
  text-align: center;  
}  
</style>

技术难点与解决方案

在实现过程中,我遇到了一些技术难点:

难点 1:原生组件的生命周期管理

问题:鸿蒙原生组件有自己的生命周期,如何与 uni-app 的页面生命周期同步?

解决方案

  • 使用 @Component 装饰器定义组件
  • build() 方法中创建 UI
  • 通过 defineNativeEmbed 注册后,uni-app 会自动管理组件生命周期

难点 2:事件通信机制

问题:原生组件的事件如何传递到 uni-app 侧?

解决方案

  • 在原生侧,通过 onSuccessonFail 回调函数传递事件
  • 使用 opts?.on?.get('success') 获取 uni-app 传递的事件监听器
  • uni-app 侧通过 @success@fail 监听事件

难点 3:参数传递

问题:uni-app 如何向原生组件传递参数?

解决方案

  • 通过 :options 属性传递配置参数
  • 原生侧通过 NativeEmbedBuilderOptions 接口接收参数
  • 支持动态更新参数(通过响应式数据)

难点 4:组件样式定制

问题:华为登录按钮的样式如何定制?

解决方案

  • 使用 loginComponentManager.Style.BUTTON_CUSTOM 自定义样式
  • 通过 .width().height() 设置组件尺寸
  • 可以在外层包裹自定义样式

五、服务端集成:获取真实手机号

客户端获取授权码后,还需要服务端配合才能获取真实手机号。这是整个流程中最关键的一步。

华为服务端接口

接口地址

POST https://account-api.cloud.huawei.com/oauth2/v6/quickLogin/getPhoneNumber

请求头

Content-Type: application/json;charset=UTF-8

请求参数

{  
  "code": "<authorizationCode>",  
  "clientId": "<your-clientId>",  
  "clientSecret": "<your-clientSecret>"  
}
参数名 类型 必填 说明
code String 客户端通过登录组件获取的授权码
clientId String 应用的 clientId
clientSecret String 应用的 clientSecret(必须保密!)

响应示例

{  
  "openId": "xxxx",  
  "unionId": "xxxx",  
  "phoneNumber": "13111111111",  
  "phoneNumberValid": 1,  
  "purePhoneNumber": "13111111111",  
  "phoneCountryCode": "0086"  
}

服务端实现示例

方案一:PHP 实现

<?php  
header('Content-Type: application/json;charset=UTF-8');  

// 获取客户端传递的授权码  
$requestData = json_decode(file_get_contents('php://input'), true);  
$code = $requestData['code'] ?? '';  

// 验证授权码  
if (empty($code)) {  
    http_response_code(400);  
    echo json_encode([  
        'success' => false,  
        'message' => '缺少授权码'  
    ]);  
    exit;  
}  

// 华为 API 配置(从环境变量或配置文件读取)  
$clientId = getenv('HUAWEI_CLIENT_ID');  
$clientSecret = getenv('HUAWEI_CLIENT_SECRET');  

// 调用华为接口获取手机号  
$url = 'https://account-api.cloud.huawei.com/oauth2/v6/quickLogin/getPhoneNumber';  
$postData = json_encode([  
    'code' => $code,  
    'clientId' => $clientId,  
    'clientSecret' => $clientSecret  
]);  

$ch = curl_init();  
curl_setopt($ch, CURLOPT_URL, $url);  
curl_setopt($ch, CURLOPT_POST, 1);  
curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);  
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);  
curl_setopt($ch, CURLOPT_HTTPHEADER, [  
    'Content-Type: application/json;charset=UTF-8'  
]);  

$response = curl_exec($ch);  
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);  
curl_close($ch);  

if ($httpCode == 200) {  
    $result = json_decode($response, true);  

    // 返回手机号给客户端  
    echo json_encode([  
        'success' => true,  
        'phoneNumber' => $result['phoneNumber'],  
        'phoneCountryCode' => $result['phoneCountryCode'],  
        'unionId' => $result['unionId'],  
        'openId' => $result['openId']  
    ]);  
} else {  
    http_response_code(500);  
    echo json_encode([  
        'success' => false,  
        'message' => '获取手机号失败',  
        'error' => $response  
    ]);  
}  
?>

安全注意事项

⚠️ 非常重要

  1. clientSecret 必须保存在服务端

    • 绝对不能写在客户端代码中
    • 应该使用环境变量或密钥管理服务
    • 定期更换 clientSecret
  2. 授权码验证

    • 授权码只能使用一次
    • 授权码有效期很短(通常几分钟)
    • 服务端应记录已使用的授权码,防止重放攻击
  3. 防刷机制

    • 限制同一用户的请求频率
    • 记录异常请求日志
    • 必要时添加图形验证码
  4. HTTPS 传输

    • 所有接口必须使用 HTTPS
    • 防止授权码在传输过程中被窃取
  5. 数据存储

    • 手机号等敏感信息应加密存储
    • 遵守数据保护法规(如 GDPR、个人信息保护法)

六、完整功能实现

基于以上技术方案,插件最终实现了三个核心功能:

1. 获取匿名手机号

快速获取用户的脱敏手机号(如:131******23),无需用户额外授权:

import { getHmAnonymousPhone } from '@/uni_modules/anhao-login'  

getHmAnonymousPhone({  
  success(res) {  
    console.log('脱敏手机号:', res.quickLoginAnonymousPhone)  
    console.log('openID:', res.openID)  
    console.log('unionID:', res.unionID)  
    console.log('本机号码一致性:', res.localNumberConsistency)  
  },  
  fail(err) {  
    console.error('获取失败:', err)  
  }  
})

适用场景

  • 快速注册场景
  • 用户身份初步识别
  • 本机号码一致性检测

2. 获取用户头像昵称

通过引导用户授权,获取用户的基础信息:

import { hmLoginWithProfile } from '@/uni_modules/anhao-login'  

hmLoginWithProfile({  
  success(res) {  
    console.log('昵称:', res.nickName)  
    console.log('头像:', res.avatarUri)  
    console.log('unionID:', res.unionID)  
  },  
  fail(err) {  
    console.error('获取失败:', err)  
  }  
})

适用场景

  • 用户资料完善
  • 社交应用的用户展示
  • 个性化推荐

3. 华为账号一键登录(核心功能)

通过华为内置登录组件,获取授权码,同时可以获取用户的头像和昵称,再通过服务端接口获取真实手机号。

完整流程

用户点击登录按钮  
     ↓  
调用华为登录组件  
     ↓  
用户确认授权  
     ↓  
获取 authorizationCode、unionID、openID  
(可同时获取头像、昵称)  
     ↓  
发送授权码到服务端  
     ↓  
服务端调用华为接口  
     ↓  
获取真实手机号  
     ↓  
返回给客户端  
     ↓  
登录成功

特别说明

  • 在用户点击登录按钮授权后,除了获取 authorizationCode,还可以同时获取用户的头像昵称
  • 这样可以一次授权完成用户的完整信息获取,无需多次交互
  • 大大提升了用户体验和开发效率

七、插件封装与开源

为什么要做成插件

在解决了集成问题后,我意识到:

  1. 这个问题具有普遍性:很多 uni-app 开发者可能都会遇到同样的问题
  2. 华为登录是刚需:鸿蒙应用都需要用户登录功能
  3. 没有现成的方案:uni-app 生态中缺少成熟的华为登录插件
  4. 技术门槛较高:涉及原生开发,对普通开发者不够友好

因此,我决定将这个功能封装成标准的 uni-app 插件,并开源出来,让更多开发者能够快速集成华为账号登录功能。

开源地址

为了方便开发者使用和贡献,插件已经在多个平台开源:

欢迎大家使用、提 Issue 和 PR,共同完善这个插件!

八、开发心得与经验总结

回顾整个集成过程,我有以下几点深刻体会:

1. 不要轻易怀疑官方文档,但也不要完全依赖

华为的官方文档提供了很多有价值的信息,但对于一些特殊限制(如 phone scope 仅限游戏应用),说明可能不够突出。

经验教训

  • 遇到问题时,先仔细阅读官方文档,特别是"注意事项"和"权限说明"部分
  • 如果文档无法解决,及时联系官方技术支持
  • 多参考官方示例代码,理解推荐的实现方式

2. 理解平台设计理念很重要

华为限制 phone scope 的使用,并不是故意增加开发难度,而是为了保护用户隐私:

  • 手机号是高度敏感的个人信息
  • 用户必须清楚知道自己在授权什么
  • 通过 UI 组件授权,确保用户的知情权

经验教训

  • 理解并尊重平台的安全机制
  • 不要尝试绕过安全限制
  • 站在用户隐私保护的角度思考问题

3. 跨平台开发需要深入原生

uni-app 虽然提供了跨平台能力,但在集成平台特有功能时,仍然需要深入原生层:

  • 理解鸿蒙原生组件的机制
  • 掌握 uni-app 的原生扩展方式
  • 熟悉原生代码与 JS 的通信机制

经验教训

  • 不要指望所有功能都能通过纯 JS 实现
  • 学习平台原生开发知识是必要的
  • 善用 DCloud 提供的原生扩展机制

4. 没有现成方案就自己创造

华为技术老师没有提供 uni-app 的集成方案,但这不是放弃的理由:

  • DCloud 提供了完善的原生组件调用文档
  • 社区有很多开发者分享的经验
  • 通过学习和实践,我们也能创造自己的方案

经验教训

  • 遇到技术挑战,先思考是否有类似的解决方案可以参考
  • 善用搜索引擎和开发者社区
  • 不要害怕深入原生层,这是技术成长的必经之路

5. 安全性永远是第一位

在整个实现过程中,安全性始终是最重要的考虑因素:

  • clientSecret 必须保存在服务端
  • 授权码必须做防重放处理
  • 用户数据传输必须使用 HTTPS

经验教训

  • 永远不要将密钥写在客户端代码中
  • 敏感操作必须在服务端完成
  • 为授权码设置有效期和使用次数限制
  • 定期进行安全审计

6. 开源是最好的学习和回馈方式

在解决问题的过程中,我参考了很多开源项目和社区讨论。当我自己解决了问题后,我也选择将方案开源:

  • 帮助他人,节省他们的时间
  • 收到反馈,促进自己的成长
  • 建立口碑,融入开发者社区
  • 共同建设更好的鸿蒙生态

开源的价值

  • 知识的传播和共享
  • 技术的迭代和改进
  • 社区的繁荣和发展
  • 个人的成长和提升

7. 感恩与协作

这次集成之所以能成功,离不开很多人的帮助:

  • 华为技术老师的耐心指导,解答了关键问题
  • DCloud 提供的完善文档和示例
  • 社区开发者的经验分享和反馈

感恩

  • 感谢华为技术团队为开发者提供的支持
  • 感谢 DCloud 搭建的跨平台开发生态
  • 感谢所有为鸿蒙生态建设贡献力量的开发者

协作

  • 多参与社区讨论,分享经验
  • 遇到问题时,整理成文档帮助后来人
  • 对他人的开源项目表示支持和感谢
  • 共同推动鸿蒙生态的发展

九、写在最后

从一个月前的困惑,到今天的豁然开朗,再到最终的开源贡献,这段经历让我深刻体会到:

技术问题没有解决不了的,关键是找对方法和寻求帮助。

回顾这次经历,最大的收获不仅仅是解决了一个技术问题,更重要的是:

  1. 学会了正确的求助方式:遇到问题时,先自己尝试,实在解决不了就及时联系官方技术支持
  2. 理解了平台设计理念:安全和隐私保护永远是第一位的
  3. 掌握了原生集成能力:在 uni-app 中调用鸿蒙原生组件
  4. 建立了开源思维:用开源的方式回馈社区
  5. 感受到了协作的力量:一个人走得快,一群人走得远

鸿蒙生态正处于快速发展阶段,还有很多开放能力等待我们去探索和实践。作为 uni-app 开发者,我们既能享受跨平台开发的便利,又能深入集成各平台的原生能力。

希望这篇文章能帮助到正在或即将集成华为账号服务的开发者们。如果你在使用插件的过程中遇到任何问题,欢迎通过以下方式联系我:

让我们一起,为鸿蒙生态的繁荣贡献自己的力量!


附录:关键技术点总结

错误方案 vs 正确方案

对比项 错误方案 正确方案
API 调用 createAuthorizationWithHuaweiIDRequest loginComponentManager + LoginWithHuaweiIDButton
权限申请 scopes: ['phone'] 通过登录组件自动处理
适用范围 仅游戏应用 普通应用
UI 要求 必须使用华为提供的 UI 组件
授权码权限 无法获取手机号 可以获取手机号
获取信息 有限 可同时获取手机号、头像、昵称、unionID、openID

uni-app 调用原生组件关键点

  1. 使用 defineNativeEmbed 注册原生组件
  2. 设置 tag 属性 为原生组件标识(如 hwilogin
  3. 通过 :options 传递配置 参数
  4. 通过 @success@fail 监听事件
  5. 使用 <embed> 标签 嵌入原生组件
  6. 处理原生组件的生命周期 和事件传递

服务端集成关键点

  1. clientSecret 必须保存在服务端
  2. 授权码只能使用一次
  3. 授权码有效期很短(几分钟)
  4. 必须使用 HTTPS 传输
  5. 添加防刷机制
  6. 记录已使用的授权码,防止重放攻击
  7. 敏感数据加密存储

华为登录组件配置要点

LoginWithHuaweiIDButton({  
  params: {  
    style: loginComponentManager.Style.BUTTON_CUSTOM,         // 按钮样式  
    loginType: loginComponentManager.LoginType.QUICK_LOGIN,   // 登录类型:一键登录  
    supportDarkMode: true,                                     // 支持深色模式  
  },  
  controller: this.controller  
})

关键参数

  • style:按钮样式,可选 BUTTON_BLUEBUTTON_WHITEBUTTON_CUSTOM
  • loginType:登录类型,QUICK_LOGIN 表示一键登录,可获取手机号
  • supportDarkMode:是否支持深色模式

关于作者

一名热爱开源的 uni-app 开发者,专注于跨平台应用开发和鸿蒙生态探索。在摸索中成长,在分享中进步。

相关链接

收起阅读 »

【鸿蒙征文】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}`)  
        }  
      })  
    )  
  }  
}  
收起阅读 »