HBuilderX

HBuilderX

极客开发工具
uni-app

uni-app

开发一次,多端覆盖
uniCloud

uniCloud

云开发平台
HTML5+

HTML5+

增强HTML5的功能体验
MUI

MUI

上万Star的前端框架

上传app store无法设置专用密码可以使用香蕉云编密钥上传代替

Appstore上传

最近苹果设置专用密码的功能打不开了,appleid.apple.com访问跳account.apple.com,但是account.apple.com打开后,只有一个转圈界面,无法打开。

如下图:

然而很多上传工具都是通过专用密码上传的,比如Transporter也是通过专用密码上传。像xcode不是通过专用密码上传的不一样,但是使用hbuilderx开发的IOS应用是打成ipa包的,不能通过xcode上传。

可以使用香蕉云编上传,香蕉云编支持使用app store密钥上传和专用密码上传两种方式。如图:

这个密钥很容易设置,不需要跑去appleid.apple.com设置,在app store的界面就可以使用,添加密钥后,就可以下载p8密钥,注意这个p8密钥只能下载一次,在下图标注的地方下载:

上面的参数中,Issuser ID和密钥ID可以直接从界面获得,p8密钥是将密钥下载下来后,用文本编辑器打开,即可获得。

继续阅读 »

最近苹果设置专用密码的功能打不开了,appleid.apple.com访问跳account.apple.com,但是account.apple.com打开后,只有一个转圈界面,无法打开。

如下图:

然而很多上传工具都是通过专用密码上传的,比如Transporter也是通过专用密码上传。像xcode不是通过专用密码上传的不一样,但是使用hbuilderx开发的IOS应用是打成ipa包的,不能通过xcode上传。

可以使用香蕉云编上传,香蕉云编支持使用app store密钥上传和专用密码上传两种方式。如图:

这个密钥很容易设置,不需要跑去appleid.apple.com设置,在app store的界面就可以使用,添加密钥后,就可以下载p8密钥,注意这个p8密钥只能下载一次,在下图标注的地方下载:

上面的参数中,Issuser ID和密钥ID可以直接从界面获得,p8密钥是将密钥下载下来后,用文本编辑器打开,即可获得。

收起阅读 »

? NanoBanana AI 图像工作室:一键开启你的视觉魔法!

图像处理 ai

厌倦了普通的AI绘图?想要拥有超高一致性清晰度的创意图像?

NanoBanana 提供的 “AI 图像工作室”,基于 Gemini-2.5-Flash-Image(也称为 Nano Banana)模型,为您带来超越传统的绘图体验!

🎯 是什么?

NanoBanana AI 图像工作室是一款集图片上传、风格转换和文生图功能于一体的专业级AI视觉创作工具。它专注于提供高品质、高一致性的图像生成服务。

核心功能亮点:

  • 风格多样: 3D手办模型、二次元周边、乐高风格等一键切换。
  • 卓越一致性: 图像细节和风格的连贯性出色,告别“AI手残”!
  • 4K高清图像: 承诺提供高分辨率的输出,满足专业用途需求。
  • 无水印下载: 创作成果纯净交付,可直接用于商业或个人项目。

💡 原理?

技术革新,定义下一代AI绘图标准!

“NanoBanana AI 图像工作室” 的核心是 Gemini-2.5-Flash-Image 模型(或称 Nano Banana 模型)。

  1. 突破传统: 官方宣称这款模型超越了 Flux Kontext 绘图模型,意味着它在图像的细节处理、主题理解和风格保持上具备更高的水平。
  2. “闪电”速度: Flash模型代表它具备极快的生成速度,让你的创意无需等待。
  3. 精确控制: 用户可以上传图片进行“图转图”(如将普通照片转为3D手办模型),或使用“自然语言”描述生成图像,模型都能精准把握需求,生成出色的作品。

⚙️ 如何用?

你的创意,只需三步实现!

  1. 第一步: 【点击链接】 进入NanoBanana图像工作室。
  2. 第二步: 【选择模式】 选择一种预设风格(如3D手办)或选择“自由输入”
    • 图转图:拖拽或点击上传你的图片。
    • 文生图:在输入框中写下你想要的画面描述。
  3. 第三步: 【生成并定制】 点击按钮,AI立即为你呈现4K高清、无水印的定制图像。

新用户有免费生成次数,立即试用!

🔗 链接 (Link)

从灵感到作品,只差一个点击的距离。

🔗 https://iris.findtruman.io/web/nano?share=L

继续阅读 »

厌倦了普通的AI绘图?想要拥有超高一致性清晰度的创意图像?

NanoBanana 提供的 “AI 图像工作室”,基于 Gemini-2.5-Flash-Image(也称为 Nano Banana)模型,为您带来超越传统的绘图体验!

🎯 是什么?

NanoBanana AI 图像工作室是一款集图片上传、风格转换和文生图功能于一体的专业级AI视觉创作工具。它专注于提供高品质、高一致性的图像生成服务。

核心功能亮点:

  • 风格多样: 3D手办模型、二次元周边、乐高风格等一键切换。
  • 卓越一致性: 图像细节和风格的连贯性出色,告别“AI手残”!
  • 4K高清图像: 承诺提供高分辨率的输出,满足专业用途需求。
  • 无水印下载: 创作成果纯净交付,可直接用于商业或个人项目。

💡 原理?

技术革新,定义下一代AI绘图标准!

“NanoBanana AI 图像工作室” 的核心是 Gemini-2.5-Flash-Image 模型(或称 Nano Banana 模型)。

  1. 突破传统: 官方宣称这款模型超越了 Flux Kontext 绘图模型,意味着它在图像的细节处理、主题理解和风格保持上具备更高的水平。
  2. “闪电”速度: Flash模型代表它具备极快的生成速度,让你的创意无需等待。
  3. 精确控制: 用户可以上传图片进行“图转图”(如将普通照片转为3D手办模型),或使用“自然语言”描述生成图像,模型都能精准把握需求,生成出色的作品。

⚙️ 如何用?

你的创意,只需三步实现!

  1. 第一步: 【点击链接】 进入NanoBanana图像工作室。
  2. 第二步: 【选择模式】 选择一种预设风格(如3D手办)或选择“自由输入”
    • 图转图:拖拽或点击上传你的图片。
    • 文生图:在输入框中写下你想要的画面描述。
  3. 第三步: 【生成并定制】 点击按钮,AI立即为你呈现4K高清、无水印的定制图像。

新用户有免费生成次数,立即试用!

🔗 链接 (Link)

从灵感到作品,只差一个点击的距离。

🔗 https://iris.findtruman.io/web/nano?share=L

收起阅读 »

分析一个免费、无广的文件传输工具!? 局域网快传神器:告别压缩,秒传大文件!

工具 文件

还在为传输几十GB的大文件而苦恼?还在忍受微信、QQ传图的画质压缩? **“局域网快传神器”** 来了!  

## 🎯 是什么?  

**局域网文件快传**是一款专为**近距离、高速度、无损文件共享**设计的跨平台工具。  

**核心功能:**  
*   **零流量:** 不消耗您的手机流量或互联网带宽。  
*   **极速传输:** 速度只取决于您的Wi-Fi/局域网性能,轻松跑满带宽,秒传高清大片、设计素材。  
*   **无损画质:** 传输原始文件,保证画质和文档格式完好无损。  
*   **跨平台:** 电脑、手机、平板,只要在同一Wi-Fi下,设备间可自由互传。  

## 💡 原理?  

这款工具利用**局域网(LAN)内的直连优势**进行文件传输,而不是先将文件上传到云端服务器再下载。  

1.  **P2P直连:** 它在同一Wi-Fi网络下的两台设备之间建立**点对点 (P2P) 连接**。  
2.  **本地通道:** 数据在您自己的路由器内部传输,不经过外部广域网(Internet)。  
3.  **安全高效:** 这不仅保证了极高的传输速度(远超普通网盘和IM工具),也大大提高了安全性,因为文件始终在您的**本地网络**内流动。  

## ⚙️ 如何用?  

**超简单三步,即刻开始传输!**  

1.  **连接**:确保您的所有设备(电脑、手机等)连接到**同一个Wi-Fi网络**。  
2.  **创建/加入**:  
    *   **发送方**:点击 **【创建房间】**,获取房间号或二维码。  
    *   **接收方**:点击 **【加入房间】**,输入房间号或扫描二维码。  
3.  **发送**:发送方将需要共享的文件**拖拽或选择**到界面中,接收方点击**【接收/下载】**即可完成高速传输!  

## 🔗 链接 (  

无需安装任何软件,打开浏览器即可使用!  

**🔗 https://iris.findtruman.io/web/lan-drop?share=L**
继续阅读 »

还在为传输几十GB的大文件而苦恼?还在忍受微信、QQ传图的画质压缩? **“局域网快传神器”** 来了!  

## 🎯 是什么?  

**局域网文件快传**是一款专为**近距离、高速度、无损文件共享**设计的跨平台工具。  

**核心功能:**  
*   **零流量:** 不消耗您的手机流量或互联网带宽。  
*   **极速传输:** 速度只取决于您的Wi-Fi/局域网性能,轻松跑满带宽,秒传高清大片、设计素材。  
*   **无损画质:** 传输原始文件,保证画质和文档格式完好无损。  
*   **跨平台:** 电脑、手机、平板,只要在同一Wi-Fi下,设备间可自由互传。  

## 💡 原理?  

这款工具利用**局域网(LAN)内的直连优势**进行文件传输,而不是先将文件上传到云端服务器再下载。  

1.  **P2P直连:** 它在同一Wi-Fi网络下的两台设备之间建立**点对点 (P2P) 连接**。  
2.  **本地通道:** 数据在您自己的路由器内部传输,不经过外部广域网(Internet)。  
3.  **安全高效:** 这不仅保证了极高的传输速度(远超普通网盘和IM工具),也大大提高了安全性,因为文件始终在您的**本地网络**内流动。  

## ⚙️ 如何用?  

**超简单三步,即刻开始传输!**  

1.  **连接**:确保您的所有设备(电脑、手机等)连接到**同一个Wi-Fi网络**。  
2.  **创建/加入**:  
    *   **发送方**:点击 **【创建房间】**,获取房间号或二维码。  
    *   **接收方**:点击 **【加入房间】**,输入房间号或扫描二维码。  
3.  **发送**:发送方将需要共享的文件**拖拽或选择**到界面中,接收方点击**【接收/下载】**即可完成高速传输!  

## 🔗 链接 (  

无需安装任何软件,打开浏览器即可使用!  

**🔗 https://iris.findtruman.io/web/lan-drop?share=L**
收起阅读 »

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

鸿蒙征文

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

一、能力集成背景

在生活服务类元服务开发过程中,我们面临三大核心痛点:一是启动速度慢,首次打开需加载大量资源,用户等待时长超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的协同使用,将问题排查周期从“天级”缩短至“小时级”;三是业务层面,量化数据驱动产品迭代,核心指标的显著提升直接带动用户留存与活跃度增长。

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

收起阅读 »

windows开发打包ios和发布到app store方法

应用上架

使用hbuilderx可以打包ios应用,不过打包ios的过程,需要ios证书,打包完后还需要分发到app store。这两步是需要mac电脑的工具去完成的。

假如使用windows电脑,就安装不了mac电脑的工具了。

其实windows电脑也可以生成证书和上传ipa文件到app store的。

可以使用香蕉云编来代替。

生成证书大概是下面三个步骤

(1)在香蕉云编生成CSR文件
(2)在苹果开发者中心生成cer文件,过程中需要提供上一步生成的CSR文件
(3)在香蕉云编将苹果生成的cer证书,转换成hbuilderx打包需要的p12私钥证书。
(4)在苹果开发者中心生成profile文件。

可以使用下面这个工具:

https://www.yunedit.com/createcert

工具打开的界面是这样的:

详细的步骤可以按照工具里面的教程来做。

生成完证书后,就可以进行打包了。

后面又需要打包,打包按下面的步骤来做:

(1)在苹果开发者中心的app store connect的app模块下,查看有没有创建app,假如没有在app store创建app先创建一个,如下图:

(2)点击APP,可以进入APP的上架界面,如下图,第一个见到的界面,就是要你上传应用截屏:

这里的截屏要求截很多种尺寸的图片,比如新的iphone,旧版的iphone、ipad等等。假如你没有这么多种设备在手用来截屏测试,可以使用香蕉云编的生成截屏工具来完成截屏。

https://www.yunedit.com/jietu

(3)最后,在这个上架的界面,拉下去,看到需要我们上传一个构建版本。这里不使用它推荐的mac系统的上传工具。这里还是使用香蕉云编来上传,工具地址:

https://www.yunedit.com/ipasend

继续阅读 »

使用hbuilderx可以打包ios应用,不过打包ios的过程,需要ios证书,打包完后还需要分发到app store。这两步是需要mac电脑的工具去完成的。

假如使用windows电脑,就安装不了mac电脑的工具了。

其实windows电脑也可以生成证书和上传ipa文件到app store的。

可以使用香蕉云编来代替。

生成证书大概是下面三个步骤

(1)在香蕉云编生成CSR文件
(2)在苹果开发者中心生成cer文件,过程中需要提供上一步生成的CSR文件
(3)在香蕉云编将苹果生成的cer证书,转换成hbuilderx打包需要的p12私钥证书。
(4)在苹果开发者中心生成profile文件。

可以使用下面这个工具:

https://www.yunedit.com/createcert

工具打开的界面是这样的:

详细的步骤可以按照工具里面的教程来做。

生成完证书后,就可以进行打包了。

后面又需要打包,打包按下面的步骤来做:

(1)在苹果开发者中心的app store connect的app模块下,查看有没有创建app,假如没有在app store创建app先创建一个,如下图:

(2)点击APP,可以进入APP的上架界面,如下图,第一个见到的界面,就是要你上传应用截屏:

这里的截屏要求截很多种尺寸的图片,比如新的iphone,旧版的iphone、ipad等等。假如你没有这么多种设备在手用来截屏测试,可以使用香蕉云编的生成截屏工具来完成截屏。

https://www.yunedit.com/jietu

(3)最后,在这个上架的界面,拉下去,看到需要我们上传一个构建版本。这里不使用它推荐的mac系统的上传工具。这里还是使用香蕉云编来上传,工具地址:

https://www.yunedit.com/ipasend

收起阅读 »

【鸿蒙征文】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 开发者,专注于跨平台应用开发和鸿蒙生态探索。在摸索中成长,在分享中进步。

相关链接

收起阅读 »

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

鸿蒙征文

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

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

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

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

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

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

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

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

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

鸿蒙征文

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

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

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

整体思路比较简单

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

了解原生写法

一图胜千言:

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

官网中提供了一个示例,

代码相对清晰:

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

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

@Component  
struct HarmoyScanLayoutComponent{}  

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

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

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

编写 uts 插件代码

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

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

import './scan.ets'

scan.ets 完整代码简附录。

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

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

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

完整的目录结构是

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

页面中使用

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

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

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

附录 scan.ets 代码

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

整体思路比较简单

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

了解原生写法

一图胜千言:

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

官网中提供了一个示例,

代码相对清晰:

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

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

@Component  
struct HarmoyScanLayoutComponent{}  

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

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

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

编写 uts 插件代码

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

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

import './scan.ets'

scan.ets 完整代码简附录。

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

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

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

完整的目录结构是

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

页面中使用

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

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

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

附录 scan.ets 代码

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

鸿蒙征文

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

插件开发背景

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

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

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

下载离线 sdk

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

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

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

微信后台录入应用信息

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

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

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

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

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

编写 uts 插件

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

工程配置与依赖。

引入企业微信 HAR 包

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

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

配置 scheme

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

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

UTS 代码翻译

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

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

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

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

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

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

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

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

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

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

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

说明:

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

页面使用

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

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

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

六. 调试要点与排错清单

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

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

继续阅读 »

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

插件开发背景

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

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

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

下载离线 sdk

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

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

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

微信后台录入应用信息

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

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

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

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

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

编写 uts 插件

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

工程配置与依赖。

引入企业微信 HAR 包

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

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

配置 scheme

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

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

UTS 代码翻译

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

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

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

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

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

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

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

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

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

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

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

说明:

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

页面使用

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

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

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

六. 调试要点与排错清单

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

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

收起阅读 »

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

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

继续阅读 »

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

收起阅读 »

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

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

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

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

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

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

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


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

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

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

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

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

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

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


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

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

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

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

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

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

重点改造四个模块:

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

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

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

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

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

2. 权限申请适配

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

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

3. 分享功能切换 provider

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

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

4. 元服务卡片开发

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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


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

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

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

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

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

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

继续阅读 »

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

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

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

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

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


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

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

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

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

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

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

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


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

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

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

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

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

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

重点改造四个模块:

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

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

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

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

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

2. 权限申请适配

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

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

3. 分享功能切换 provider

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

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

4. 元服务卡片开发

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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


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

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

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

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

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

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

收起阅读 »

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

vivo unipush

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

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

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

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

(注:

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

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

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

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

继续阅读 »

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

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

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

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

(注:

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

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

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

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

收起阅读 »