diff --git a/docs/harmony-hybrid/index.md b/docs/harmony-hybrid/index.md new file mode 100644 index 000000000000..0e37ea695bd2 --- /dev/null +++ b/docs/harmony-hybrid/index.md @@ -0,0 +1,1213 @@ +--- +title: Harmony Hybrid +--- + +:::info +Taro v3.6.24+ 开始支持 +::: + +## 简介 + +Taro Harmony Hybrid容器是为让Taro小程序代码可以完整的运行在鸿蒙单内核系统里,在Taro H5平台的基础上,基于原生壳工程的jsbridge能力,扩展H5平台不支持的小程序Api能力,让Taro小程序0成本运行在鸿蒙系统。 + +此方案不同于Taro的Harmony原生方案,其运行时还是Webview,重点解决Api切换的问题。 + +![taroharmonyhybrid](@site/static/img/taroharmonyhybrid.jpg) + + +## 快速编译运行 + +### H5侧编译运行 + +Taro CLI 依赖于 Node.js 环境,所以在你的机器上需要安装 Node.js 环境。请确保已具备较新的 node 环境(>=16.20.0)。当你的机器已经存在了 Node.js 环境,可以通过在终端输入命令 npm i -g `@tarojs/cli` 安装 Taro CLI。安装完毕之后,在终端输入命令 taro,如果出现类似内容就说明安装成功了: + +```shell +👽 Taro v3.6.25 +Usage: taro [options] +Options: + -V, --version output the version number + -h, --help output usage information +``` + +安装好 Taro CLI 之后可以通过 taro init 命令创建一个全新的项目,你可以根据你的项目需求填写各个选项,一个最小版本的 Taro 项目会包括以下文件: + +``` +├── babel.config.js # Babel 配置 +├── .eslintrc.js # ESLint 配置 +├── config # 编译配置目录 +│ ├── dev.js # 开发模式配置 +│ ├── index.js # 默认配置 +│ └── prod.js # 生产模式配置 +├── package.json # Node.js manifest +├── dist # 打包目录 +├── project.config.json # 小程序项目配置 +├── src # 源码目录 +│ ├── app.config.js # 全局配置 +│ ├── app.css # 全局 CSS +│ ├── app.js # 入口组件 +│ ├── index.html # H5 入口 HTML +│ └── pages # 页面组件 +│ └── index +│ ├── index.config.js # 页面配置 +│ ├── index.css # 页面 CSS +│ └── index.jsx # 页面组件,如果是 Vue 项目,此文件为 index.vue +``` + +通过 Taro CLI 工具基于`默认模板`创建 Taro 工程,使用如下编译命令生成 harmony-hybrid 平台的 Web 资源: + +```shell +# yarn +$ yarn build:harmony-hybrid +# npm script +$ npm run build:harmony-hybrid +# pnpm script +$ pnpm build:harmony-hybrid +``` + +生成的产物在 `dist` 目录下 + +### 壳工程编译运行 +@华为-漆灿 + +Taro `harmony-hybrid` 平台产物需要结合鸿蒙壳工程一起编译运行才能实现完整的功能。`@hybrid/web-container` 三方库模块提供了鸿蒙 `TaroWebContainer` 等组件和 API,支持开发者快速构建运行环境。鸿蒙壳工程使用 `@hybrid/web-container` 模块的方式如下: + +- 使用DevEco Studio开发工具( >= 5.0.3.100 )新建应用工程,选择Empty Ability模板,API >= 11, 其他配置使用默认值。 +- 在 `entry/oh-package.json5` 文件中添加 `@hybrid/web-container` 模块的依赖并点击Sync进行同步: +```json +{ + "license": "ISC", + "devDependencies": {}, + "name": "entry", + "description": "演示如何使用TaroWebContainer组件", + "version": "1.0.0", + "dependencies": { + "@hybrid/web-container": "2.0.0-rc.1" + } +} +``` +- 使用 `@hybrid/web-container` +```typescript +import { TaroWebContainer } from '@hybrid/web-container'; +``` +具体使用方式请参考下个章节:集成到现有鸿蒙工程 + +- 最后点击菜单 `Build` -- `Rebuild Project` 编译项目。 + +## 集成到现有鸿蒙工程 + +### 简单集成(TaroWebContainer) +@华为-漆灿 + +`TaroWebContainer` 组件提供加载单页面 Web 应用能力,并提供部分 Taro API 鸿蒙版本。 + +**使用方法:** +```typescript +// entry/src/main/ets/pages/Index.ets +import Want from '@ohos.app.ability.Want'; +import Url from '@ohos.url'; +import { TaroWebContainer, InjectObject, HostPageState, TaroWebController, Logger, LoggerLevel, wbLogger } from '@hybrid/web-container'; +const SAMPLE_INDEX_TAG = 'SampleIndex'; +@Component +struct TaroMpharmonySample { + @State pageState: HostPageState = HostPageState.PageInit; + @State taroWebController: TaroWebController = new TaroWebController(); + // 用户可以自定义对象注入到Web环境中,使用native.sayHello格式进行调用 + nativeObj: InjectObject = { + sayHello: () => console.log('Hello World'), + } + onBackPress() { + if (this.taroWebController.accessBackward()) { + this.taroWebController.backward(); + return true; + } + return false; + } + aboutToAppear() { + Logger.setLogLevel(LoggerLevel.LOG_DEBUG); + } + onPageShow() { + this.pageState = HostPageState.PageOnShow; + } + onPageHide() { + this.pageState = HostPageState.PageOnHide; + } + webUrl(): string { + // 开发阶段可以把网站静态资源文件放置到src/main/resources/rawfile/目录下 + // 生产环境下可以把部分网页静态资源放置到web服务器, 这里填写实际的网站地址url + return 'resource://rawfile/index.html'; + } + webUrlPrefix() { + try { + const url = Url.URL.parseURL(this.webUrl()); + return `${url.protocol}//${url.host}/`; + } catch (err) { + wbLogger.error(SAMPLE_INDEX_TAG, `Invalid webUrl: ${this.webUrl()}`); + return ''; + } + } + build() { + Column() { + TaroWebContainer({ + pageState: this.pageState, // 页面状态同步到组件 + webUrl: this.webUrl(), // 初始Url + webUrlPrefix: this.webUrlPrefix(), + useCache: true, + taroWebController: this.taroWebController, + isFullScreen: true, // 是否全屏显示 + injectObj: this.nativeObj, // 注入对象 + }) + .width('100%') + .height('100%') + } + } +} +``` + +**构造参数说明:** + +| 参数名称 | 类型 | 描述 | 必填 | +|-------------------|-------------------|-------------------------------------------------------|-----------------------| +| taroWebController | TaroWebController | TaroWebContainer组件的控制器 | 是 | +| webUrl | string | 资源入口url | 是 | +| webUrlPrefix | string | 资源入口url的前缀,一般是 `${webUrl.protocol}://${webUrl.host}/` | 是 | +| pageState | HostPageState | 传递页面状态 | 是 | +| useCache | boolean | 是否优先使用应用内置的Web资源 | 否,默认值: true | +| want | Want | 传递EntryAbility中`onCreate`和`onNewWant`保存的want信息 | 否,默认值: { } | +| isFullScreen | boolean | 是否全屏显示应用 | 否,默认值: true | +| injectObj | ESObject | 注入ets对象到Web环境 | 否:默认值:undefined | +| showCapsule | boolean | 是否显示胶囊按钮 | 否:默认值:true | +| capsulePage | string | 点击胶囊按钮跳转的页面 | 否:默认值:`pages/Capsule` | +| enableWebDebug | boolean | [开启Web调试功能][Web调试devtools配置] | 否:默认值:true | +| navigationInitVisible | boolean | 控制导航栏初始显示状态 | 否:默认值:true | + + +### 多容器和容器共用集成(TaroHybrid) + +**使用方法:** +```typescript +// windowStage.loadContent里初始化 和 预加载 +windowStage.loadContent('xxx', storage, (err, data) => { + // 全局初始化 + TaroHybridManager.init({ + uiAbilityContex: this.context, + domain: 'https://customer.domain.com', // 小程序的域名,注意:此处不添加/结尾 + injectNativeMethod: (uiAbilityContext: common.UIAbilityContext) => { // 扩展原生API + ... + } + }) + // 预加载,可选,不是必须 + TaroHybridManager.preLoader( + windowStage.getMainWindowSync().getUIContext(), + "/xx/index.html", // html的path路由 + "/pages/index/index" // Taro小程序的Page路径 + ) +}) + +// 创建TaroHybrid页面(例子是基于Entry实现) +export interface TaroHybridRouterParams { + indexHtmlPath: string, + taroPath: string +} +export const RouterToTaroHybrid = (params: TaroHybridRouterParams) => { + // 跳转之前先加载url,目的是为了提升新页面的打开速度 + TaroHybridManager.loadUrl(params.indexHtmlPath, params.taroPath); + + setTimeout(()=>{ + router.pushUrl({ + url: 'xxx/TaroHybridPage', // 鸿蒙原生Page的路径,不是Taro小程序的Page + params: params + }, router.RouterMode.Standard) + }, 200) +} + +@Entry +@Component +struct TaroHybridPage { + @State pageState: HostPageState = HostPageState.PageInit; + private indexHtmlPath: string = (router.getParams() as TaroHybridRouterParams).indexHtmlPath + private taroPath: string = (router.getParams() as TaroHybridRouterParams).taroPath + + onPageShow(): void { + this.pageState = HostPageState.PageOnShow + } + onPageHide(): void { + this.pageState = HostPageState.PageOnHide + } + onBackPress(): boolean | void { + const instance = TaroHybridManager.getCoreInstance(this.indexHtmlPath) + if (!instance) { + return false; + } + return TaroHybridManager.onBack(instance.builderData.taroWebController) + } + + build() { + Stack({alignContent: Alignment.TopStart}){ + SafeArea(){ + TaroHybrid({ + indexHtmlPath: this.indexHtmlPath, + taroPath: this.taroPath, + pageState: this.pageState + }) + } + } + } +} + +``` + +**TaroHybrid的设计思路:** +1. 多容器的判断依据:html的Path路径为判断依赖,相同则共用,不同则新建载体页 +2. 容器共用的思路:通过鸿蒙的NodeContainer + NodeController实现 +3. 注意: + 1. 容器共用存在一个问题:相邻两个原生Page之间如何共用容器,页面切换动画时,会有一个页面白屏,进入和退出时都会出现,尽量避免相邻两个原生Page之间共用容器。 + +## 进阶教程 + +### 混合路由 + +在原生与TaroHybrid混合鸿蒙应用中,如果使用了容器共用,用户的路由栈会比较复杂,当在Taro Hybrid页面时,用户使用原生的物理返回时,需要区分是Web容器的返回,还是原生的返回。 + +TaroHybrid组件已经解决了此问题,其思路为: +1. 原生跳转打开的taro页面,添加query参数from=native +2. 原生的onBackPress逻辑里,获取当前url,判断有没有参数from=native,如果有则走原生路由返回,如果没有则走Web组件的backward逻辑 + +```typescript +static onBack(taroWebController: TaroWebController): boolean { + let curUrl = taroWebController.webController.getUrl(); + + if (curUrl?.endsWith('?from=native')) { //web回退到原生 + // Web先返回 + if (taroWebController.accessBackward()) { + setTimeout(()=>{ + taroWebController.backward(); + }, 200) + } + // 回退到原生 + return false; + } + if (taroWebController?.accessBackward()) { // web回退 + taroWebController?.backward(); + // 保留在当前Web页面 + return true; + } + // 回退到原生 + return false; + } +``` + +### 小程序内置及热更新 +#### 内置及热更新的使用方式 +##### 小程序内置规则 +在业务较为复杂的场景时,项目通常分为多个`bundle`,内置到应用时遵循以下目录结构规则: +``` +├──rawfile # 应用rawfile目录 +│ └──spa # 多bundle内置目录,支持配置 +│ ├──spa_main@100000 # 小程序1的bundle +│ │ └──spa # 一级目录 +│ │ └──main # 二级目录 +│ │ ├──js # js目录 +│ │ ├──css # css目录 +│ │ ├──static # 静态资源目录 +│ │ └──index.html # 首页文件 +│ ├──spa_new@100000 # 小程序2的bundle +│ │ └──spa # 一级目录 +│ │ └──new # 二级目录 +│ │ ├──js # js目录 +│ │ ├──css # css目录 +│ │ ├──static # 静态资源目录 +│ │ └──index.html # 首页文件 +``` +以上目录结构的解释说明 +1. rawfile目录下的spa,为多bundle的内置目录,容器会在此目录下读取bundle加载,支持以下方式配置(该目录要与配置一致): +```typescript +GlobalThis.getInstance().setRawFile('spa') +``` +2. bundle包名称:`一级目录`+`_`+`二级目录`+`@`+`VersionCode`,例如:spa_main@100000,以`@`符分割为两部分: +2.1 `@`符分前置: 为bundle的打包目录层级,一般为index.html(首页文件)的path路径,用下划线`_`分割,作用是为内置文件路径拦截匹配查找; +2.2 VersionCode: 为当前bundle版本号,作用为版本控制及磁盘热更新 +##### 小程序磁盘缓存 +1. 读取磁盘缓存,磁盘更新操作入口(如下),读取应用磁盘`${context.filesDir}/update`下bundle缓存版本信息: +```typescript +LocalUpdateManager.updateMiniOnLaunch() +``` +2. 内置更新缓存至磁盘,若磁盘缓存bundle版本 < rawfile内置版本,则将内置bundle更新至磁盘,具体实现参见以下方法: +``` +LocalUpdateManager.updateAllMini() +``` +3. 更新至磁盘的bundle在容器加载时会被自动查找加载; +4. 磁盘缓存默认开启,支持以下方式关闭: +```typescript +GlobalThis.getInstance().setDiskUpdateEnable(false) +``` +##### 热更新 +开发中。。。 +#### 整体方案简介 + +动态下开发台吗,在不发布新版本的情况下修复`bug`和发布新功能,绕开应用商店的审核机制,避免长时间审核以及多次被拒绝造成的成本问题,缩短用户取得新版本的流程,改善用户体验。 + +#### 具体实现 + +##### 1、资源拦截并加载本地资源 + +在业务较为复杂的场景时,项目通常分为多个`bundle`,热更新模块能够支持更新单独某个`bundle`服务。在`web`组件的`onInterceptRequest`中,可以进行资源拦截。通过`url`解析出`path`后,通过`path`获取对应的`bundle`信息,进而构建相应的响应返回给`web`组件。`MiniRawFileDataSource.getBizByPath`可以获取本地缓存中的`bundle`信息,`MiniLocalFileDataSource.fileExist`可以根据位置判断资源是否存在。 + +```ts +path = path.split('#')[0] +wbLogger.debug('TaroWeb', 'intercepter path=%{public}s', path) +let biz = MiniRawFileDataSource.getBizByPath(path) //获取业务标识 +const lastVerInfo = LocalUpdateManagerInstance.getLastVersionInfo(biz) +if (lastVerInfo?.diskCachePath) { // 走本地缓存 + if (!path || !MiniLocalFileDataSource.fileExist(lastVerInfo?.diskCachePath, path)) { + return null; + } + wbLogger.debug('TaroWeb', 'useLocalCacheResource path=%{public}s', path) + return MiniLocalFileDataSource.getFileOnResponseWeb(lastVerInfo?.diskCachePath, path) + } +``` + +##### 入口函数--`updateMiniOnLaunch` + +入口函数`updateMiniOnLaunch`接收一个`context`,首先初始化了基类`UpdateManager`,然后获取了`rawfile`目录下的文件内容,并传入到了`updateAllMini`中进行逻辑处理。 + +```ts +updateMiniOnLaunch(context: AppContext.UIAbilityContext) { + this.reset(context) + wbLogger.debug('LocalUpdateManager',`update start`); + this.updateAllMini(context,this.miniRepository.getMiniRawList(context)).then((result: Array) => { + if (result) { + wbLogger.debug('LocalUpdateManager',`update success ${result}`); + } + }).catch((error: Error) => { + wbLogger.error('LocalUpdateManager','update Error :', error); + }) +} +``` + + + +##### 1、获取`rawfile`文件信息--`getMiniRawList` + +从全局存储中拿出初始化时存储的`rawfile`文件名,根据该文件名称,获取该文件夹下所有的文件信息。 + +```ts +getRawMiniList(context: common.Context): Array{ + let rawFile = GlobalThis.getInstance().getRawFile(); + return context.resourceManager.getRawFileListSync(rawFile); +} +``` + +##### 2、更新所有的资源包信息---`updateAllMini` + +该函数接收一个`context`和`rawfile`文件信息,返回一个`Promise`对象。通过`rawfile`文件信息,构建`LocalVersionInfo`实例对象,该对象存储了对应文件的版本号、业务标识`biz`, 以及`rawfile`路径,将其存储到`UpdateConfig`单例的`Map`对象中,这样在资源拦截时可以从该`Map`中通过相应的业务标识获取到对应资源的信息。`rawfile`目录下的资源可能不是最新的,需要与本地缓存进行对比,如果本地缓存中没有任何资源,则需要将`rawfile`的资源复制到本地沙箱相应位置中,这样可以通过字节码优化,能提高性能。由于`rawfile`文件复制到了沙箱,这样沙箱资源一直都是最新的,所以需要更新存储在`UpdateConfig`单例的`Map`对象的资源信息。 + +```ts + async updateAllMini(context: AppContext.UIAbilityContext,miniList:Array) : Promise>{ + return new Promise((resolve, reject) => { + let miniArray = new Array() + try { + if (miniList) { + for (let index = 0; index < miniList.length; index++) { + const element = miniList[index]; + let splits = FileUtils.checkMiniFileNameFormat(element) + let miniInfo: LocalVersionInfo | null = null + if (splits) { + miniInfo = new LocalVersionInfo(splits[1], splits[0],'',this.miniRepository.getRawCachePath(element)) + UpdateConfig.getInstance().setOrReplaceMiniInfo(miniInfo.biz, miniInfo) + } + if (miniInfo) { + if (GlobalThis.getInstance().getDiskUpdateEnable()) { + this.updateDiskSingleMini(context, miniInfo) + } + wbLogger.debug('LocalUpdateManager',`VersionInfo miniInfo:${miniInfo.localVersion},biz:${miniInfo.biz},diskCachePath:${miniInfo.diskCachePath},rawCachePath:${miniInfo.rawCachePath}`); + miniArray.push(miniInfo) + } + } + //TODO-JasonZhang 热更新接口请求 + } + resolve(miniArray) + } catch (err) { + reject(err) + } + }) + } + updateDiskSingleMini(context: AppContext.UIAbilityContext,versionInfo:LocalVersionInfo){ + // 获取本地最新的版本号 + let localVersionInfo = this.miniRepository.readDiskLastMiniBizInfo(context,versionInfo.biz) + // 本地无内置版本,则迁移RawFile小程序资源到磁盘缓存目录为初始版本 + if (localVersionInfo && (localVersionInfo?.diskCachePath==''|| localVersionInfo?.localVersion < versionInfo.localVersion)) { + let result = this.miniRepository.copyMiniRawUnzipDisk(context,versionInfo) + if (result) { + versionInfo.localVersion = result.localVersion + versionInfo.diskCachePath = result.diskCachePath + } + wbLogger.debug('LocalUpdateManager',`copied success ${result}`); + }else{ + versionInfo.localVersion = localVersionInfo.localVersion + versionInfo.diskCachePath = localVersionInfo.diskCachePath + } + } +``` + +#### 使用说明 + +##### `arkts`端配置 + +热更新配置分为三步,首先需要配置`rawfile`内资源顶层文件夹名称,以壳工程资源为例`rawfile/spa/`,需要配置`spa`名称;其次,需要配置云端资源信息的`url`生成函数,这样当资源需要更新时可以从该地址获取相应信息;最后,启动热更新模块。 + +```tsx +onCreate(want: Want) { + // ... other code + GlobalThis.getInstance().setRawFile('spa') + UpdateConfig.getInstance(). + LocalUpdateManagerInstance.updateMiniOnLaunch(this.context) + } +``` + +###### 1、配置位置 + +热更新的启动位置,一般放在某个页面的`aboutToAppear`或者应用被打开时的`onCreate`中,热更新涉及有`http`请求,尽量减少热更新模块的执行。 + +###### 2、导入包 + +需要导入`LocalUpdateManagerInstance`实例,以及配置热更新模块的对象`UpdateConfig` + +``` tsx +import {LocalUpdateManagerInstance, UpdateConfig} from '@ohos/web-container' +``` + +###### 3、热更新初始配置 + +在执行入口函数前,需要对热更新模块进行配置初始化。`UpdateConfig`对象可以配置四个参数,**第一个参数和第二个参数为必须配置参数,第三个和第四个参数为选配**。其中第一个参数为最新离线资源配置的地址,需要用户按照字段配置好;第二个参数为初始的版本号(可以是`rawfile`路径下资源的版本),该版本号将会与第一个参数进行拼接,形成完成的`url`。原生端根据形成的`url`发起请求以获取服务端离线资源配置信息,服务端依据相关规则将字段返回,分两种情况,**需要更新离线资源和本地资源是最新的**。**后续的更新与该版本号无关,只与服务端最新离线资源配置相关。** + +```tsx +const updateConfig = new UpdateConfig('http://127.0.0.1:8080:/apis/config?local_version=', '0') +LocalUpdateManagerInstance.setUpdateConfig(updateConfig) +``` + +**1、需要更新离线资源** + +假设初始版本号为`0`,服务端的最新离线资源版本为`1`,则需要更新。服务端收到原生端的请求后,获取`local_version`字段进行对比,根据相关规则将字段传回原生端。服务端配置字段可以参照如下所示: + +```json +{"code": 200, "message": "request OK", "data": {"mini_download_url": "http://127.0.0.1/mpharmony/mpharmony.zip", "mini_version": 1}} +``` + +服务端需要配置上述四个字段,其中,`data`字段中的`mini_download_url`字段表示需要下载离线资源路径,热更新模块依据这个路径进行下载最新配置。 + +**注意:这里的`0`只是用例,后续的更新与该版本号无关,只与服务端最新离线资源配置相关。** + +**2、本地资源是最新的** + +假设初始版本号为`1`,服务端的最新离线资源版本为`1`,则不需要更新。服务端收到原生端的请求后,获取`local_version`字段进行对比,根据相关规则将字段传回原生端,服务端配置字段可以参照如下所示: + +```json +{"code": "300", "message": "latest version"} +``` + +**注意:这里的`1`只是用例,后续的更新与该版本号无关,只与服务端最新离线资源配置相关。** + +###### 4、排序算法配置(选配) + +此外,热更新模块会保留两个历史版本,会在下次启动时删除多余版本。此时需要对离线版本进行排序,默认支持的排序算法是`2.1.0 < 11.0.1`。用户可以自己实现排序算法用以适配不同的版本号,伪代码如下所示: + +```ts +/** + * + * @param v1 + * @param v2 + * @returns {boolean} is v1 bigger than v2 + */ + +function versionCompare(v1: string, v2: string) { + if (v1 > v2){ + return true + } + return false +} +``` + +热更新模块配置如下所示: + +```tsx +const updateConfig = new UpdateConfig('http://127.0.0.1:8080:/apis/config?local_version=', '0', true, versionCompare) +LocalUpdateManagerInstance.setUpdateConfig(updateConfig) +``` + +第三个参数为是否开启自定义排序算法配置,默认为`false`,`false`会执行默认的排序算法 + +4、启动,启动热更新模块入口函数时需要传递当前页面的上下文,下载的离线资源会依据上下文存储。 + +```tsx +LocalUpdateManagerInstance.updateMiniOnLaunch(this.context) +``` + +##### `js`端配置 + +**注意:因为该配置实现的方法需要在原生端被调用,所以需要这些方法要在热更新模块启动前进行配置,如果放在热更新启动后,可能无法生效** + +热更新模块提供了一个对象,注入到了`js`端,这样用户可以依据业务来决定是否需要更新。 + +注入到`js`端的方法如下所示: + +```tsx +applyUpdate(){} +onCheckForUpdate(listener: (hasUpdate: boolean)=>void){} +onUpdateReady(listener: (updatedPath: string)=>void){} +onUpdateFailed(listener: ()=>void){} +``` + +用户可以在`js`端通过预先注入的代理对象`native`调用这些方法,用以实现相应的业务,实现例子如下所示: + +```ts +const updateManager = Taro.getUpdateManager() +updateManager.onCheckForUpdate(function listener(hasUpdate: boolean){ + if (!hasUpdate){ + return + } + updateManager.onUpdateReady(function listener(updatedPath: string){ + updateManager.applyUpdate() + }) +}) +updateManager.onUpdateFailed(function listener(){ + console.log("UpdateFailed") +}) +``` + +该例子实现的功能是:调用原生端注入到`js`的的`getUpdateManager`方法,获取`LocalUpdateManagerInstance`实例,通过该实例调用了`onCheckForUpdate`方法,传递了一个参数`listener`,该参数为一个函数,里面包含了业务逻辑。 + +### 扩展原生Api + +当Taro API不满足业务需求时,可以扩展原生API,实现小程序端调用原生功能。先由原生部分实现JSBridge方法,再由小程序部分注册该方法并调用。 + +#### 原生部分 + +##### 定义InjectObject + +示例代码如下: + +```typescript + +interface LoginOptions { + username: string + password: string + success: (t: string | null | undefined) => void + error: (error: BusinessError) => void +} + +export const nativeObj:InjectObject = { + customLogin:(options: LoginOptions) => { + // 登录... + options.success("xxx") + } +} +``` + +##### 初始化注入InjectObject + +```typescript +TaroHybridManager.init({ + uiAbilityContex: this.context, + domain: 'https://xxx.xxx.com', // 注意:此处不添加/结尾 + injectNativeMethod: (uiAbilityContext: common.UIAbilityContext) => { + return nativeObj + } + }) +``` + +##### 注意事项 + +1. 扩展的方法中只能定义一个参数,上面的例子中我们定义LoginOptions接口来封装全部参数。 +2. 扩展的方法参数中只要最上层可以包含方法,嵌套的对象中不能包含方法,上面的例子中LoginOptions中包含了success和error方法,如果把success和error方法放到一个单独的Callback interface中就不可行了。 + +#### 小程序部分 + +##### 方法声明 + +通过@window.MethodChannel.jsBridgeMode装饰器声明方法,具体代码如下: + +```typescript +// @proxyClassSign('') +class NativeApi { + + // @ts-ignore + @window.MethodChannel.jsBridgeMode({ isAsync: true, autoRelease: true }) + customLogin (option: any) { + return option//这里固定返回option即可 + } +} + +const native = new NativeApi() + +export default native +``` + +参数说明: + +isAsync:如果参数option中定义了回调方法则为true,否则为false + +autoRelease:如果option中有回调方法且回调方法可能会调用多次则设置为false,否则就为true + +##### 方法调用 + +```typescript + +import { native } from './NativeApi'; + +native.customLogin({ + username: 'xxx', + password: 'xxx', + success: () => { + console.log('Login success!') + }, + error: (e) => { + console.error('Login error.', e) + } +}) + +``` + +### 鸿蒙一多适配指导 + +#### 鸿蒙折叠屏适配指导 + +默认情况下,设计尺寸是750px,Taro适配不同手机分辨率的方式是等比缩放,在鸿蒙折叠屏展开的状态下,等比放大的效果不满足华为应用商店上架要求。 + +**建议的适配方案:** +1. 最大放大比例不超过1.2倍 +2. 宽度不基于750px来布局,基于flex自适应 + +最大放大比例不超过1.1倍的配置: +```javascript +// 修改config/index.js +export default defineConfig(async (merge, { command, mode }) => { + const baseConfig = { + // .... + h5: { + // .... + postcss: { + pxtransform: { + enable: true, + config: { + baseFontSize: 20, + maxRootSize: 24, // 最大不超过1.2倍 + minRootSize: 10, // 最小不超过0.5倍 + } + }, + // .... + } + }, + // .... + } + // .... +}) +``` + + +### 沉浸式适配指导 + +[鸿蒙官方适配指南](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/arkts-develop-apply-immersive-effects-0000001820435461-V5#section231547103814)提供了两个种方案:窗口全屏布局方案 及 组件安全区方案 + +建议使用“窗口全屏布局方案”,默认全屏模式,再处理状态栏及导航条的避让。 + +**全屏模式下的状态样的避让:** 通过原生创建一个SafeArea组件实现,而不通过JS实现 +```typescript +// 在UIAbility里,全局保存状态栏的安全高度 +const systemAvoidArea = windowClass.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM); +const topRectHeight = systemAvoidArea.topRect.height +AppStorage.setOrCreate('topRectHeight', `${topRectHeight}px`); + +// 自定义SafeArea组件 +@Component +export struct SafeArea { + @StorageProp('topRectHeight') topRectHeight: string = '0px' + + @BuilderParam child: ()=>void + + build() { + Column() { + Row() + .width('100%') + .height(this.topRectHeight) + .backgroundColor(0xFFFFFF) + Column(){ + this.child() + } + .width('100%') + .layoutWeight(1) + } + .width('100%') + .height('100%') + } +} + +// Taro Hybrid的布局 +@Entry +@Component +struct TaroHybridPage { + build() { + Stack(){ + SafeArea(){ + TaroHybrid() + } + } + } +} +``` + +**全屏模式下的导航条的避让:** 通过鸿蒙Web组件支持安全边界,给H5的底部bar设置margin +```css +padding-bottom: env( safe-area-inset-bottom); +``` + +`注意:` 支持安全区域的鸿蒙单内核系统暂未发布 + + +### 访问控制权限二次弹窗指导 +@华为-漆灿 + +### 性能优化:同步调用走缓存 +taro的NativeApi,是taro暴露给鸿蒙实现的原生方法。使得Taro具备调用原生的能力。 + +对于NativeApi中的同步方法,每次调用都要执行原生代码逻辑,频繁调用必然导致系统开销增加和耗时增加。所以taro内部对同步方法增加了缓存机制。比如"getSystemSetting"、"getWindowInfo"等。 +如果应用层想扩展,对额外的同步方法使用缓存机制以提高执行效率,则可以通过如下步骤扩展使用缓存的方法: + +#### 明确使用缓存的方法名 +``` +let list = new ArrayList(); +list.add("youMethodName1") +list.add("youMethodName2") + +TaroHybridManager.setCacheSyncMethods(list) +``` + +list是一个方法名的列表,用于标识哪些NativeApi中的方法使用缓存。这里的youMethodName1、youMethodName2为要使用缓存的方法名。 + +#### 监听数据变化更新缓存 +同步方法使用缓存机制可以极大加快api响应速度,但是需要及时更新缓存中的数据。开发者需要在合适的时机监听数据变化,并通过如下方法更新数据 + + +``` +nativeUpdater.update(p: NativeApiPair, v?: any) +``` +举例说明: + +1. youMethodName1方法执行全量数据更新(无入参的情况): + +``` +nativeUpdater.update(new NativeApiPair("YouMethodName1",[])) +``` + + +2. youMethodName1方法执行全量数据更新(有入参的情况): + +``` +nativeUpdater.update(new NativeApiPair("youMethodName1",["param1",123])) +``` +3. youMethodName1方法执行局部数据更新(仅更新其中某个字段:myField): + +``` +nativeUpdater.update(new NativeApiPair("youMethodName1",["param1",123],"myField"),"newData") +``` +#### 注意 +1. 使用缓存机制的方法,必须是同步方法。 +2. 使用缓存机制的方法,在数据发生变化时必须及时更新缓存,并且对数据发生变化的场景做全方位覆盖。否则会带来获取到的数据不是最新的问题。 + + +### Taro.request请求方式选择(原生/js) + +#### 一、Taro.request 原生实现(native桥接)代码位置:taro/packages/taro-platform-harmony-hybrid/src/api/apis/network/request/index.ts + +```typescript + +let task!: Taro.RequestTask + const result: ReturnType = new Promise((resolve, reject) => { + const upperMethod = method ? method.toUpperCase() : method + const taskID = native.request({ + url, + method: upperMethod, + ...otherOptions, + success: (res: any) => { + isFunction(success) && success(res) + isFunction(complete) && complete(res) + resolve(res) + }, + fail: (res: any) => { + isFunction(fail) && fail(res) + isFunction(complete) && complete(res) + reject(res) + }, + }) + task = judgeUseAxios ? taskID : NativeRequest.getRequestTask(taskID) + }) as any +``` + +#### 二、Taro.request js实现(axios)代码位置:taro/packages/taro-platform-harmony-hybrid/src/api/apis/request.ts + +```typescript + +this.httpRequest = axios.create({ + responseType: responseType || 'text', + headers: headers, + timeout: timeout || 2000, + }) + + // 请求拦截器 + this.httpRequest.interceptors.request.use( + (config) => { + if (config.enableCache === false) { + return config + } + // 处理缓存 + const cacheData = localStorage.getItem(config.url) + if (cacheData !== null) { + let result = cacheData + if (dataType === 'json') { + result = JSON.parse(cacheData) + } + source.cancel('cache has useful data!!') + return Promise.resolve({ result }) + } + return config + }, + (error) => { + console.error('error: ', error) + } + ) + + // 响应拦截器 + this.httpRequest.interceptors.response.use( + (response) => { + // 缓存数据 + if (response.config.enableCache === false) { + localStorage.setItem(response.config.url, JSON.stringify(response.data)) + } + callbackManager.headersReceived.trigger({ + header: response.headers + }) + return response + }, + (error) => { + console.error('error: ', error) + } + ) + + if (!object) { + console.error('request error: params illegal') + return + } + + let isFormUrlEncoded = false + for (const key in headers) { + if (key.toLowerCase() === 'content-type') { + if (headers[key].toLowerCase().includes('application/x-www-form-urlencoded')) { + isFormUrlEncoded = true + } + break + } + } + + // data为Object类型时,属性的值类型如果是number, request请求时信息会丢失. 故将data转成string类型进行规避 + if (data && (isFormUrlEncoded || ['GET', 'OPTIONS', 'DELETE', 'TRACE', 'CONNECT'].includes(method))) { + const dataArray = [] + for (const key in data) { + // @ts-ignore + dataArray.push(encodeURIComponent(key) + '=' + encodeURIComponent(data[key])) + } + data = dataArray.join('&') + } + + // header的属性的值类型如果是number, request请求时信息会丢失. 故将各个属性转成string类型 + if (headers) { + for (const key in headers) { + headers[key] = `${headers[key]}` + } + } + + this.httpRequest({ + method: method, + url: url, + CancelToken: source.token, + enableCache: enableCache || false, + }) + .then((response) => { + if (success && !this.abortFlag) { + let result = response.result + if (response.config.responseType === 'text') { + if (dataType === 'text') { + result = response.data + } else if (dataType === 'json') { + result = JSON.parse(response.data) + } else if (dataType === 'base64') { + const encodeData = encodeURIComponent(response.data) + result = btoa(encodeData) + } else if (dataType === 'arraybuffer') { + result = new TextEncoder().encode(response.data).buffer + } else { + console.error('Unsupported dataType!!') + } + } else if (response.config.responseType === 'arraybuffer') { + result = response.data + } else { + console.error('Unsupported dataType!!: ', response.config.responseType) + } + const res = { + data: result, + statusCode: response.status, + header: response.headers, + cookies: response.cookies ? [response.cookies] : [], + errMsg: 'request:ok', + } + this.result = res + success(res) + } + }) + .catch((err) => { + console.error('request error: ' + JSON.stringify(err)) + if (fail && !this.abortFlag) { + // eslint-disable-next-line no-console + const res = { errMsg: errMsgMap.has(err.code) ? errMsgMap.get(err.code) : `${JSON.stringify(err)}` } + this.result = res + fail(res) + } + }) + .finally(() => { + if (complete && !this.abortFlag) { + complete(this.result) + } + if (this.httpRequest) { + source.cancel('requestTask cancelled by the user!') + } + }) +``` + +#### 三、原生、js方式转换实现(proxy代理)代码位置:taro/packages/taro-platform-harmony-hybrid/src/api/apis/NativeApi.ts + +```typescript + +class HybridProxy { + private readonly useAxios: boolean + private readonly useOsChannel: boolean + private readonly cacheProxy: any + private readonly requestApi = 'request' + + constructor (useAxios: boolean, useOsChannel: boolean, nativeApi: NativeApi) { + this.useAxios = useAxios + this.useOsChannel = useOsChannel + this.cacheProxy = new Proxy(nativeApi, new CacheStorageProxy(nativeApi)) + } + + get (_target: any, prop: string) { + return (...args: any) => { + if (this.useAxios && prop === this.requestApi) { + judgeUseAxios = this.useAxios + // @ts-ignore + return new RequestTask(...args) + } + if (this.useOsChannel && osChannelApi.hasOwnProperty(prop)) { + return osChannelApi[prop](...args) + } + return this.cacheProxy[prop](...args) + } + } +} + +const nativeApi = new NativeApi() +const native = new Proxy(nativeApi, new HybridProxy(false, false, nativeApi)) // 第一个false是默认走jsb,true是走纯js, 第二个false是不走osChannel +export default native +``` +#### 注意 +1、转换需要手动修改 const native = new Proxy(nativeApi, new HybridProxy(false, false, nativeApi)) 中 new HybridProxy的第一个参数,改为false为原生实现,若改为true,则为js实现。 + +### 同层渲染 +预计后续530支持 + +`@hybrid/web-container` 提供 `sameLayerManager.registerNativeComponentBuilders` 方法来注册同层渲染组件。 + +**参数说明:** + +| 参数名称 | 类型 | 描述 | 必填 | +|---------------|----------------------------------------------------|----------------------|---------------------| +| componentName | string | 对应embed标签type属性,去掉 `native/` 前缀 | 是 | +| builder | (...args: ESObject[]) => void | 使用 `@Builder` 注解的原生组件builder函数 | 是 | + + +**使用方法:** +- 壳工程中,注册自定义组件: + +```typescript +import { sameLayerManager } from '@hybrid/web-container' + +export default class EntryAbility extends UIAbility { + onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) { + sameLayerManager.registerNativeComponentBuilders('hos-video', NativeVideoBuilder) + } + // ... +} + +``` +- Video 自定义组件实现参考 + +```typescript +import { sameLayerManager } from '@hybrid/web-container'; + +@Observed +export class VideoParams { + width: number // 组件宽度 + height: number // 组件高度 + componentId: string // 组件ID + src: string // 要播放视频的资源地址 + controls?: boolean // 是否显示播放控件 + muted?: boolean // 是否静音 + loop?: boolean + autoPlay?: boolean + onPlay?: (res?: string) => void + onPause?: (res?: string) => void + + constructor() { + this.width = 0; + this.height = 0; + this.componentId = ''; + this.src = ''; + } +} + +@Component +export struct NativeVideo { + @ObjectLink params: VideoParams + videoController: VideoController = new VideoController(); + + build() { + Column() { + Video({ + src: this.params.src, + controller: this.videoController + }) + .objectFit(ImageFit.Fill) + .autoPlay(this.params.autoPlay ?? true) + .muted(this.params.muted ?? false) + .loop(this.params.loop ?? false) + .controls(this.params.controls ?? true) + .onClick((event) => { + console.log('NativeEmbed video onClick.') + }) + .onStart(() => { + console.log('NativeEmbed video onPlay.', this.params.height) + const callJsOnPlay: Function | undefined = (sameLayerManager.getSameLayerArgs(this.params.componentId) as VideoParams)?.onPlay + callJsOnPlay && callJsOnPlay('Native Video playing.') + }) + .onPause(() => { + console.log('NativeEmbed video onPause.') + const callJsOnPause: Function | undefined = (sameLayerManager.getSameLayerArgs(this.params.componentId) as VideoParams)?.onPause + callJsOnPause && callJsOnPause('Native Video paused.') + }) + } + .width(this.params.width) + .height(this.params.height) + } +} + +@Builder +export function NativeVideoBuilder(params: VideoParams) { + NativeVideo({ params: params }) + .backgroundColor(Color.Green) +} + +``` + +- 前端代码中,使用 `react` 框架实现的代码如下: +```typescript +import React from 'react' +import classNames from 'classnames' + +import './index.scss' + +let videoId = 0 + +interface IProps extends React.HTMLAttributes { + src: string + controls?: boolean + autoplay?: boolean + loop?: boolean + muted?: boolean + onPlay?: (res: any) => void + onPause?: (res: any) => void +} + +export default class HosVideo extends React.Component { + private componentId: string + + constructor (props: IProps | Readonly) { + super(props) + this.componentId = `video_${videoId++}` + } + + componentDidMount () { + this.transferVideoProps() + } + + componentDidUpdate (_prevProps: IProps, _prevState: any) { + // 组件更新时重新传输数据 + this.transferVideoProps() + } + + transferVideoProps () { + // 同层渲染video组件数据 + const { + src, + controls, + autoplay, + loop, + muted, + onPlay, + onPause + } = this.props + + const properties = { + componentId: this.componentId, + src: src, + controls: controls, + autoPlay: autoplay, + loop: loop, + muted: muted, + onPlay: onPlay, + onPause: onPause + } + + // @ts-ignore 调用JSB方法传递原生组件数据 + window.JSBridge && window.JSBridge.transferSameLayerArgs(properties) + } + + render () { + const { + style, + className + } = this.props + + return ( +
+ +
+ ) + } +} +``` + + +## FAQ + +### 原生导航条影响Fixed布局 + +**原因:** Web容器与原生导航栏之间是层叠布局,为了不影响H5页面的内容展示,Taro Harmony给每个Page设置了一个padding-top值,其值来自原生注入的一个变量 + +```typescript +// Web组件加载时,原生会给js侧注入的代码: +var navigationHeight = ${this.navigationBarHeight}; + +// 使用方法 +window.navigationHeight +``` + +当存在原生导航栏时,fixed布局需要添加一个top值,其值为window.navigationHeight + +[Web调试devtools配置]: https://gitee.com/openharmony/docs/blob/master/zh-cn/application-dev/web/web-debugging-with-devtools.md diff --git a/sidebars.js b/sidebars.js index 80d764ddc9c5..d7705515c0f9 100644 --- a/sidebars.js +++ b/sidebars.js @@ -71,7 +71,8 @@ module.exports = { type: 'category', items: ['react-native', 'react-native-remind','react-native-harmony'], }, - 'harmony' + 'harmony', + 'harmony-hybrid/index' ], 进阶指南: [ 'config-detail', diff --git a/static/img/taroharmonyhybrid.jpg b/static/img/taroharmonyhybrid.jpg new file mode 100644 index 000000000000..d4f3595a98c9 Binary files /dev/null and b/static/img/taroharmonyhybrid.jpg differ diff --git a/versioned_docs/version-3.x/harmony-hybrid/index.md b/versioned_docs/version-3.x/harmony-hybrid/index.md new file mode 100644 index 000000000000..0e37ea695bd2 --- /dev/null +++ b/versioned_docs/version-3.x/harmony-hybrid/index.md @@ -0,0 +1,1213 @@ +--- +title: Harmony Hybrid +--- + +:::info +Taro v3.6.24+ 开始支持 +::: + +## 简介 + +Taro Harmony Hybrid容器是为让Taro小程序代码可以完整的运行在鸿蒙单内核系统里,在Taro H5平台的基础上,基于原生壳工程的jsbridge能力,扩展H5平台不支持的小程序Api能力,让Taro小程序0成本运行在鸿蒙系统。 + +此方案不同于Taro的Harmony原生方案,其运行时还是Webview,重点解决Api切换的问题。 + +![taroharmonyhybrid](@site/static/img/taroharmonyhybrid.jpg) + + +## 快速编译运行 + +### H5侧编译运行 + +Taro CLI 依赖于 Node.js 环境,所以在你的机器上需要安装 Node.js 环境。请确保已具备较新的 node 环境(>=16.20.0)。当你的机器已经存在了 Node.js 环境,可以通过在终端输入命令 npm i -g `@tarojs/cli` 安装 Taro CLI。安装完毕之后,在终端输入命令 taro,如果出现类似内容就说明安装成功了: + +```shell +👽 Taro v3.6.25 +Usage: taro [options] +Options: + -V, --version output the version number + -h, --help output usage information +``` + +安装好 Taro CLI 之后可以通过 taro init 命令创建一个全新的项目,你可以根据你的项目需求填写各个选项,一个最小版本的 Taro 项目会包括以下文件: + +``` +├── babel.config.js # Babel 配置 +├── .eslintrc.js # ESLint 配置 +├── config # 编译配置目录 +│ ├── dev.js # 开发模式配置 +│ ├── index.js # 默认配置 +│ └── prod.js # 生产模式配置 +├── package.json # Node.js manifest +├── dist # 打包目录 +├── project.config.json # 小程序项目配置 +├── src # 源码目录 +│ ├── app.config.js # 全局配置 +│ ├── app.css # 全局 CSS +│ ├── app.js # 入口组件 +│ ├── index.html # H5 入口 HTML +│ └── pages # 页面组件 +│ └── index +│ ├── index.config.js # 页面配置 +│ ├── index.css # 页面 CSS +│ └── index.jsx # 页面组件,如果是 Vue 项目,此文件为 index.vue +``` + +通过 Taro CLI 工具基于`默认模板`创建 Taro 工程,使用如下编译命令生成 harmony-hybrid 平台的 Web 资源: + +```shell +# yarn +$ yarn build:harmony-hybrid +# npm script +$ npm run build:harmony-hybrid +# pnpm script +$ pnpm build:harmony-hybrid +``` + +生成的产物在 `dist` 目录下 + +### 壳工程编译运行 +@华为-漆灿 + +Taro `harmony-hybrid` 平台产物需要结合鸿蒙壳工程一起编译运行才能实现完整的功能。`@hybrid/web-container` 三方库模块提供了鸿蒙 `TaroWebContainer` 等组件和 API,支持开发者快速构建运行环境。鸿蒙壳工程使用 `@hybrid/web-container` 模块的方式如下: + +- 使用DevEco Studio开发工具( >= 5.0.3.100 )新建应用工程,选择Empty Ability模板,API >= 11, 其他配置使用默认值。 +- 在 `entry/oh-package.json5` 文件中添加 `@hybrid/web-container` 模块的依赖并点击Sync进行同步: +```json +{ + "license": "ISC", + "devDependencies": {}, + "name": "entry", + "description": "演示如何使用TaroWebContainer组件", + "version": "1.0.0", + "dependencies": { + "@hybrid/web-container": "2.0.0-rc.1" + } +} +``` +- 使用 `@hybrid/web-container` +```typescript +import { TaroWebContainer } from '@hybrid/web-container'; +``` +具体使用方式请参考下个章节:集成到现有鸿蒙工程 + +- 最后点击菜单 `Build` -- `Rebuild Project` 编译项目。 + +## 集成到现有鸿蒙工程 + +### 简单集成(TaroWebContainer) +@华为-漆灿 + +`TaroWebContainer` 组件提供加载单页面 Web 应用能力,并提供部分 Taro API 鸿蒙版本。 + +**使用方法:** +```typescript +// entry/src/main/ets/pages/Index.ets +import Want from '@ohos.app.ability.Want'; +import Url from '@ohos.url'; +import { TaroWebContainer, InjectObject, HostPageState, TaroWebController, Logger, LoggerLevel, wbLogger } from '@hybrid/web-container'; +const SAMPLE_INDEX_TAG = 'SampleIndex'; +@Component +struct TaroMpharmonySample { + @State pageState: HostPageState = HostPageState.PageInit; + @State taroWebController: TaroWebController = new TaroWebController(); + // 用户可以自定义对象注入到Web环境中,使用native.sayHello格式进行调用 + nativeObj: InjectObject = { + sayHello: () => console.log('Hello World'), + } + onBackPress() { + if (this.taroWebController.accessBackward()) { + this.taroWebController.backward(); + return true; + } + return false; + } + aboutToAppear() { + Logger.setLogLevel(LoggerLevel.LOG_DEBUG); + } + onPageShow() { + this.pageState = HostPageState.PageOnShow; + } + onPageHide() { + this.pageState = HostPageState.PageOnHide; + } + webUrl(): string { + // 开发阶段可以把网站静态资源文件放置到src/main/resources/rawfile/目录下 + // 生产环境下可以把部分网页静态资源放置到web服务器, 这里填写实际的网站地址url + return 'resource://rawfile/index.html'; + } + webUrlPrefix() { + try { + const url = Url.URL.parseURL(this.webUrl()); + return `${url.protocol}//${url.host}/`; + } catch (err) { + wbLogger.error(SAMPLE_INDEX_TAG, `Invalid webUrl: ${this.webUrl()}`); + return ''; + } + } + build() { + Column() { + TaroWebContainer({ + pageState: this.pageState, // 页面状态同步到组件 + webUrl: this.webUrl(), // 初始Url + webUrlPrefix: this.webUrlPrefix(), + useCache: true, + taroWebController: this.taroWebController, + isFullScreen: true, // 是否全屏显示 + injectObj: this.nativeObj, // 注入对象 + }) + .width('100%') + .height('100%') + } + } +} +``` + +**构造参数说明:** + +| 参数名称 | 类型 | 描述 | 必填 | +|-------------------|-------------------|-------------------------------------------------------|-----------------------| +| taroWebController | TaroWebController | TaroWebContainer组件的控制器 | 是 | +| webUrl | string | 资源入口url | 是 | +| webUrlPrefix | string | 资源入口url的前缀,一般是 `${webUrl.protocol}://${webUrl.host}/` | 是 | +| pageState | HostPageState | 传递页面状态 | 是 | +| useCache | boolean | 是否优先使用应用内置的Web资源 | 否,默认值: true | +| want | Want | 传递EntryAbility中`onCreate`和`onNewWant`保存的want信息 | 否,默认值: { } | +| isFullScreen | boolean | 是否全屏显示应用 | 否,默认值: true | +| injectObj | ESObject | 注入ets对象到Web环境 | 否:默认值:undefined | +| showCapsule | boolean | 是否显示胶囊按钮 | 否:默认值:true | +| capsulePage | string | 点击胶囊按钮跳转的页面 | 否:默认值:`pages/Capsule` | +| enableWebDebug | boolean | [开启Web调试功能][Web调试devtools配置] | 否:默认值:true | +| navigationInitVisible | boolean | 控制导航栏初始显示状态 | 否:默认值:true | + + +### 多容器和容器共用集成(TaroHybrid) + +**使用方法:** +```typescript +// windowStage.loadContent里初始化 和 预加载 +windowStage.loadContent('xxx', storage, (err, data) => { + // 全局初始化 + TaroHybridManager.init({ + uiAbilityContex: this.context, + domain: 'https://customer.domain.com', // 小程序的域名,注意:此处不添加/结尾 + injectNativeMethod: (uiAbilityContext: common.UIAbilityContext) => { // 扩展原生API + ... + } + }) + // 预加载,可选,不是必须 + TaroHybridManager.preLoader( + windowStage.getMainWindowSync().getUIContext(), + "/xx/index.html", // html的path路由 + "/pages/index/index" // Taro小程序的Page路径 + ) +}) + +// 创建TaroHybrid页面(例子是基于Entry实现) +export interface TaroHybridRouterParams { + indexHtmlPath: string, + taroPath: string +} +export const RouterToTaroHybrid = (params: TaroHybridRouterParams) => { + // 跳转之前先加载url,目的是为了提升新页面的打开速度 + TaroHybridManager.loadUrl(params.indexHtmlPath, params.taroPath); + + setTimeout(()=>{ + router.pushUrl({ + url: 'xxx/TaroHybridPage', // 鸿蒙原生Page的路径,不是Taro小程序的Page + params: params + }, router.RouterMode.Standard) + }, 200) +} + +@Entry +@Component +struct TaroHybridPage { + @State pageState: HostPageState = HostPageState.PageInit; + private indexHtmlPath: string = (router.getParams() as TaroHybridRouterParams).indexHtmlPath + private taroPath: string = (router.getParams() as TaroHybridRouterParams).taroPath + + onPageShow(): void { + this.pageState = HostPageState.PageOnShow + } + onPageHide(): void { + this.pageState = HostPageState.PageOnHide + } + onBackPress(): boolean | void { + const instance = TaroHybridManager.getCoreInstance(this.indexHtmlPath) + if (!instance) { + return false; + } + return TaroHybridManager.onBack(instance.builderData.taroWebController) + } + + build() { + Stack({alignContent: Alignment.TopStart}){ + SafeArea(){ + TaroHybrid({ + indexHtmlPath: this.indexHtmlPath, + taroPath: this.taroPath, + pageState: this.pageState + }) + } + } + } +} + +``` + +**TaroHybrid的设计思路:** +1. 多容器的判断依据:html的Path路径为判断依赖,相同则共用,不同则新建载体页 +2. 容器共用的思路:通过鸿蒙的NodeContainer + NodeController实现 +3. 注意: + 1. 容器共用存在一个问题:相邻两个原生Page之间如何共用容器,页面切换动画时,会有一个页面白屏,进入和退出时都会出现,尽量避免相邻两个原生Page之间共用容器。 + +## 进阶教程 + +### 混合路由 + +在原生与TaroHybrid混合鸿蒙应用中,如果使用了容器共用,用户的路由栈会比较复杂,当在Taro Hybrid页面时,用户使用原生的物理返回时,需要区分是Web容器的返回,还是原生的返回。 + +TaroHybrid组件已经解决了此问题,其思路为: +1. 原生跳转打开的taro页面,添加query参数from=native +2. 原生的onBackPress逻辑里,获取当前url,判断有没有参数from=native,如果有则走原生路由返回,如果没有则走Web组件的backward逻辑 + +```typescript +static onBack(taroWebController: TaroWebController): boolean { + let curUrl = taroWebController.webController.getUrl(); + + if (curUrl?.endsWith('?from=native')) { //web回退到原生 + // Web先返回 + if (taroWebController.accessBackward()) { + setTimeout(()=>{ + taroWebController.backward(); + }, 200) + } + // 回退到原生 + return false; + } + if (taroWebController?.accessBackward()) { // web回退 + taroWebController?.backward(); + // 保留在当前Web页面 + return true; + } + // 回退到原生 + return false; + } +``` + +### 小程序内置及热更新 +#### 内置及热更新的使用方式 +##### 小程序内置规则 +在业务较为复杂的场景时,项目通常分为多个`bundle`,内置到应用时遵循以下目录结构规则: +``` +├──rawfile # 应用rawfile目录 +│ └──spa # 多bundle内置目录,支持配置 +│ ├──spa_main@100000 # 小程序1的bundle +│ │ └──spa # 一级目录 +│ │ └──main # 二级目录 +│ │ ├──js # js目录 +│ │ ├──css # css目录 +│ │ ├──static # 静态资源目录 +│ │ └──index.html # 首页文件 +│ ├──spa_new@100000 # 小程序2的bundle +│ │ └──spa # 一级目录 +│ │ └──new # 二级目录 +│ │ ├──js # js目录 +│ │ ├──css # css目录 +│ │ ├──static # 静态资源目录 +│ │ └──index.html # 首页文件 +``` +以上目录结构的解释说明 +1. rawfile目录下的spa,为多bundle的内置目录,容器会在此目录下读取bundle加载,支持以下方式配置(该目录要与配置一致): +```typescript +GlobalThis.getInstance().setRawFile('spa') +``` +2. bundle包名称:`一级目录`+`_`+`二级目录`+`@`+`VersionCode`,例如:spa_main@100000,以`@`符分割为两部分: +2.1 `@`符分前置: 为bundle的打包目录层级,一般为index.html(首页文件)的path路径,用下划线`_`分割,作用是为内置文件路径拦截匹配查找; +2.2 VersionCode: 为当前bundle版本号,作用为版本控制及磁盘热更新 +##### 小程序磁盘缓存 +1. 读取磁盘缓存,磁盘更新操作入口(如下),读取应用磁盘`${context.filesDir}/update`下bundle缓存版本信息: +```typescript +LocalUpdateManager.updateMiniOnLaunch() +``` +2. 内置更新缓存至磁盘,若磁盘缓存bundle版本 < rawfile内置版本,则将内置bundle更新至磁盘,具体实现参见以下方法: +``` +LocalUpdateManager.updateAllMini() +``` +3. 更新至磁盘的bundle在容器加载时会被自动查找加载; +4. 磁盘缓存默认开启,支持以下方式关闭: +```typescript +GlobalThis.getInstance().setDiskUpdateEnable(false) +``` +##### 热更新 +开发中。。。 +#### 整体方案简介 + +动态下开发台吗,在不发布新版本的情况下修复`bug`和发布新功能,绕开应用商店的审核机制,避免长时间审核以及多次被拒绝造成的成本问题,缩短用户取得新版本的流程,改善用户体验。 + +#### 具体实现 + +##### 1、资源拦截并加载本地资源 + +在业务较为复杂的场景时,项目通常分为多个`bundle`,热更新模块能够支持更新单独某个`bundle`服务。在`web`组件的`onInterceptRequest`中,可以进行资源拦截。通过`url`解析出`path`后,通过`path`获取对应的`bundle`信息,进而构建相应的响应返回给`web`组件。`MiniRawFileDataSource.getBizByPath`可以获取本地缓存中的`bundle`信息,`MiniLocalFileDataSource.fileExist`可以根据位置判断资源是否存在。 + +```ts +path = path.split('#')[0] +wbLogger.debug('TaroWeb', 'intercepter path=%{public}s', path) +let biz = MiniRawFileDataSource.getBizByPath(path) //获取业务标识 +const lastVerInfo = LocalUpdateManagerInstance.getLastVersionInfo(biz) +if (lastVerInfo?.diskCachePath) { // 走本地缓存 + if (!path || !MiniLocalFileDataSource.fileExist(lastVerInfo?.diskCachePath, path)) { + return null; + } + wbLogger.debug('TaroWeb', 'useLocalCacheResource path=%{public}s', path) + return MiniLocalFileDataSource.getFileOnResponseWeb(lastVerInfo?.diskCachePath, path) + } +``` + +##### 入口函数--`updateMiniOnLaunch` + +入口函数`updateMiniOnLaunch`接收一个`context`,首先初始化了基类`UpdateManager`,然后获取了`rawfile`目录下的文件内容,并传入到了`updateAllMini`中进行逻辑处理。 + +```ts +updateMiniOnLaunch(context: AppContext.UIAbilityContext) { + this.reset(context) + wbLogger.debug('LocalUpdateManager',`update start`); + this.updateAllMini(context,this.miniRepository.getMiniRawList(context)).then((result: Array) => { + if (result) { + wbLogger.debug('LocalUpdateManager',`update success ${result}`); + } + }).catch((error: Error) => { + wbLogger.error('LocalUpdateManager','update Error :', error); + }) +} +``` + + + +##### 1、获取`rawfile`文件信息--`getMiniRawList` + +从全局存储中拿出初始化时存储的`rawfile`文件名,根据该文件名称,获取该文件夹下所有的文件信息。 + +```ts +getRawMiniList(context: common.Context): Array{ + let rawFile = GlobalThis.getInstance().getRawFile(); + return context.resourceManager.getRawFileListSync(rawFile); +} +``` + +##### 2、更新所有的资源包信息---`updateAllMini` + +该函数接收一个`context`和`rawfile`文件信息,返回一个`Promise`对象。通过`rawfile`文件信息,构建`LocalVersionInfo`实例对象,该对象存储了对应文件的版本号、业务标识`biz`, 以及`rawfile`路径,将其存储到`UpdateConfig`单例的`Map`对象中,这样在资源拦截时可以从该`Map`中通过相应的业务标识获取到对应资源的信息。`rawfile`目录下的资源可能不是最新的,需要与本地缓存进行对比,如果本地缓存中没有任何资源,则需要将`rawfile`的资源复制到本地沙箱相应位置中,这样可以通过字节码优化,能提高性能。由于`rawfile`文件复制到了沙箱,这样沙箱资源一直都是最新的,所以需要更新存储在`UpdateConfig`单例的`Map`对象的资源信息。 + +```ts + async updateAllMini(context: AppContext.UIAbilityContext,miniList:Array) : Promise>{ + return new Promise((resolve, reject) => { + let miniArray = new Array() + try { + if (miniList) { + for (let index = 0; index < miniList.length; index++) { + const element = miniList[index]; + let splits = FileUtils.checkMiniFileNameFormat(element) + let miniInfo: LocalVersionInfo | null = null + if (splits) { + miniInfo = new LocalVersionInfo(splits[1], splits[0],'',this.miniRepository.getRawCachePath(element)) + UpdateConfig.getInstance().setOrReplaceMiniInfo(miniInfo.biz, miniInfo) + } + if (miniInfo) { + if (GlobalThis.getInstance().getDiskUpdateEnable()) { + this.updateDiskSingleMini(context, miniInfo) + } + wbLogger.debug('LocalUpdateManager',`VersionInfo miniInfo:${miniInfo.localVersion},biz:${miniInfo.biz},diskCachePath:${miniInfo.diskCachePath},rawCachePath:${miniInfo.rawCachePath}`); + miniArray.push(miniInfo) + } + } + //TODO-JasonZhang 热更新接口请求 + } + resolve(miniArray) + } catch (err) { + reject(err) + } + }) + } + updateDiskSingleMini(context: AppContext.UIAbilityContext,versionInfo:LocalVersionInfo){ + // 获取本地最新的版本号 + let localVersionInfo = this.miniRepository.readDiskLastMiniBizInfo(context,versionInfo.biz) + // 本地无内置版本,则迁移RawFile小程序资源到磁盘缓存目录为初始版本 + if (localVersionInfo && (localVersionInfo?.diskCachePath==''|| localVersionInfo?.localVersion < versionInfo.localVersion)) { + let result = this.miniRepository.copyMiniRawUnzipDisk(context,versionInfo) + if (result) { + versionInfo.localVersion = result.localVersion + versionInfo.diskCachePath = result.diskCachePath + } + wbLogger.debug('LocalUpdateManager',`copied success ${result}`); + }else{ + versionInfo.localVersion = localVersionInfo.localVersion + versionInfo.diskCachePath = localVersionInfo.diskCachePath + } + } +``` + +#### 使用说明 + +##### `arkts`端配置 + +热更新配置分为三步,首先需要配置`rawfile`内资源顶层文件夹名称,以壳工程资源为例`rawfile/spa/`,需要配置`spa`名称;其次,需要配置云端资源信息的`url`生成函数,这样当资源需要更新时可以从该地址获取相应信息;最后,启动热更新模块。 + +```tsx +onCreate(want: Want) { + // ... other code + GlobalThis.getInstance().setRawFile('spa') + UpdateConfig.getInstance(). + LocalUpdateManagerInstance.updateMiniOnLaunch(this.context) + } +``` + +###### 1、配置位置 + +热更新的启动位置,一般放在某个页面的`aboutToAppear`或者应用被打开时的`onCreate`中,热更新涉及有`http`请求,尽量减少热更新模块的执行。 + +###### 2、导入包 + +需要导入`LocalUpdateManagerInstance`实例,以及配置热更新模块的对象`UpdateConfig` + +``` tsx +import {LocalUpdateManagerInstance, UpdateConfig} from '@ohos/web-container' +``` + +###### 3、热更新初始配置 + +在执行入口函数前,需要对热更新模块进行配置初始化。`UpdateConfig`对象可以配置四个参数,**第一个参数和第二个参数为必须配置参数,第三个和第四个参数为选配**。其中第一个参数为最新离线资源配置的地址,需要用户按照字段配置好;第二个参数为初始的版本号(可以是`rawfile`路径下资源的版本),该版本号将会与第一个参数进行拼接,形成完成的`url`。原生端根据形成的`url`发起请求以获取服务端离线资源配置信息,服务端依据相关规则将字段返回,分两种情况,**需要更新离线资源和本地资源是最新的**。**后续的更新与该版本号无关,只与服务端最新离线资源配置相关。** + +```tsx +const updateConfig = new UpdateConfig('http://127.0.0.1:8080:/apis/config?local_version=', '0') +LocalUpdateManagerInstance.setUpdateConfig(updateConfig) +``` + +**1、需要更新离线资源** + +假设初始版本号为`0`,服务端的最新离线资源版本为`1`,则需要更新。服务端收到原生端的请求后,获取`local_version`字段进行对比,根据相关规则将字段传回原生端。服务端配置字段可以参照如下所示: + +```json +{"code": 200, "message": "request OK", "data": {"mini_download_url": "http://127.0.0.1/mpharmony/mpharmony.zip", "mini_version": 1}} +``` + +服务端需要配置上述四个字段,其中,`data`字段中的`mini_download_url`字段表示需要下载离线资源路径,热更新模块依据这个路径进行下载最新配置。 + +**注意:这里的`0`只是用例,后续的更新与该版本号无关,只与服务端最新离线资源配置相关。** + +**2、本地资源是最新的** + +假设初始版本号为`1`,服务端的最新离线资源版本为`1`,则不需要更新。服务端收到原生端的请求后,获取`local_version`字段进行对比,根据相关规则将字段传回原生端,服务端配置字段可以参照如下所示: + +```json +{"code": "300", "message": "latest version"} +``` + +**注意:这里的`1`只是用例,后续的更新与该版本号无关,只与服务端最新离线资源配置相关。** + +###### 4、排序算法配置(选配) + +此外,热更新模块会保留两个历史版本,会在下次启动时删除多余版本。此时需要对离线版本进行排序,默认支持的排序算法是`2.1.0 < 11.0.1`。用户可以自己实现排序算法用以适配不同的版本号,伪代码如下所示: + +```ts +/** + * + * @param v1 + * @param v2 + * @returns {boolean} is v1 bigger than v2 + */ + +function versionCompare(v1: string, v2: string) { + if (v1 > v2){ + return true + } + return false +} +``` + +热更新模块配置如下所示: + +```tsx +const updateConfig = new UpdateConfig('http://127.0.0.1:8080:/apis/config?local_version=', '0', true, versionCompare) +LocalUpdateManagerInstance.setUpdateConfig(updateConfig) +``` + +第三个参数为是否开启自定义排序算法配置,默认为`false`,`false`会执行默认的排序算法 + +4、启动,启动热更新模块入口函数时需要传递当前页面的上下文,下载的离线资源会依据上下文存储。 + +```tsx +LocalUpdateManagerInstance.updateMiniOnLaunch(this.context) +``` + +##### `js`端配置 + +**注意:因为该配置实现的方法需要在原生端被调用,所以需要这些方法要在热更新模块启动前进行配置,如果放在热更新启动后,可能无法生效** + +热更新模块提供了一个对象,注入到了`js`端,这样用户可以依据业务来决定是否需要更新。 + +注入到`js`端的方法如下所示: + +```tsx +applyUpdate(){} +onCheckForUpdate(listener: (hasUpdate: boolean)=>void){} +onUpdateReady(listener: (updatedPath: string)=>void){} +onUpdateFailed(listener: ()=>void){} +``` + +用户可以在`js`端通过预先注入的代理对象`native`调用这些方法,用以实现相应的业务,实现例子如下所示: + +```ts +const updateManager = Taro.getUpdateManager() +updateManager.onCheckForUpdate(function listener(hasUpdate: boolean){ + if (!hasUpdate){ + return + } + updateManager.onUpdateReady(function listener(updatedPath: string){ + updateManager.applyUpdate() + }) +}) +updateManager.onUpdateFailed(function listener(){ + console.log("UpdateFailed") +}) +``` + +该例子实现的功能是:调用原生端注入到`js`的的`getUpdateManager`方法,获取`LocalUpdateManagerInstance`实例,通过该实例调用了`onCheckForUpdate`方法,传递了一个参数`listener`,该参数为一个函数,里面包含了业务逻辑。 + +### 扩展原生Api + +当Taro API不满足业务需求时,可以扩展原生API,实现小程序端调用原生功能。先由原生部分实现JSBridge方法,再由小程序部分注册该方法并调用。 + +#### 原生部分 + +##### 定义InjectObject + +示例代码如下: + +```typescript + +interface LoginOptions { + username: string + password: string + success: (t: string | null | undefined) => void + error: (error: BusinessError) => void +} + +export const nativeObj:InjectObject = { + customLogin:(options: LoginOptions) => { + // 登录... + options.success("xxx") + } +} +``` + +##### 初始化注入InjectObject + +```typescript +TaroHybridManager.init({ + uiAbilityContex: this.context, + domain: 'https://xxx.xxx.com', // 注意:此处不添加/结尾 + injectNativeMethod: (uiAbilityContext: common.UIAbilityContext) => { + return nativeObj + } + }) +``` + +##### 注意事项 + +1. 扩展的方法中只能定义一个参数,上面的例子中我们定义LoginOptions接口来封装全部参数。 +2. 扩展的方法参数中只要最上层可以包含方法,嵌套的对象中不能包含方法,上面的例子中LoginOptions中包含了success和error方法,如果把success和error方法放到一个单独的Callback interface中就不可行了。 + +#### 小程序部分 + +##### 方法声明 + +通过@window.MethodChannel.jsBridgeMode装饰器声明方法,具体代码如下: + +```typescript +// @proxyClassSign('') +class NativeApi { + + // @ts-ignore + @window.MethodChannel.jsBridgeMode({ isAsync: true, autoRelease: true }) + customLogin (option: any) { + return option//这里固定返回option即可 + } +} + +const native = new NativeApi() + +export default native +``` + +参数说明: + +isAsync:如果参数option中定义了回调方法则为true,否则为false + +autoRelease:如果option中有回调方法且回调方法可能会调用多次则设置为false,否则就为true + +##### 方法调用 + +```typescript + +import { native } from './NativeApi'; + +native.customLogin({ + username: 'xxx', + password: 'xxx', + success: () => { + console.log('Login success!') + }, + error: (e) => { + console.error('Login error.', e) + } +}) + +``` + +### 鸿蒙一多适配指导 + +#### 鸿蒙折叠屏适配指导 + +默认情况下,设计尺寸是750px,Taro适配不同手机分辨率的方式是等比缩放,在鸿蒙折叠屏展开的状态下,等比放大的效果不满足华为应用商店上架要求。 + +**建议的适配方案:** +1. 最大放大比例不超过1.2倍 +2. 宽度不基于750px来布局,基于flex自适应 + +最大放大比例不超过1.1倍的配置: +```javascript +// 修改config/index.js +export default defineConfig(async (merge, { command, mode }) => { + const baseConfig = { + // .... + h5: { + // .... + postcss: { + pxtransform: { + enable: true, + config: { + baseFontSize: 20, + maxRootSize: 24, // 最大不超过1.2倍 + minRootSize: 10, // 最小不超过0.5倍 + } + }, + // .... + } + }, + // .... + } + // .... +}) +``` + + +### 沉浸式适配指导 + +[鸿蒙官方适配指南](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/arkts-develop-apply-immersive-effects-0000001820435461-V5#section231547103814)提供了两个种方案:窗口全屏布局方案 及 组件安全区方案 + +建议使用“窗口全屏布局方案”,默认全屏模式,再处理状态栏及导航条的避让。 + +**全屏模式下的状态样的避让:** 通过原生创建一个SafeArea组件实现,而不通过JS实现 +```typescript +// 在UIAbility里,全局保存状态栏的安全高度 +const systemAvoidArea = windowClass.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM); +const topRectHeight = systemAvoidArea.topRect.height +AppStorage.setOrCreate('topRectHeight', `${topRectHeight}px`); + +// 自定义SafeArea组件 +@Component +export struct SafeArea { + @StorageProp('topRectHeight') topRectHeight: string = '0px' + + @BuilderParam child: ()=>void + + build() { + Column() { + Row() + .width('100%') + .height(this.topRectHeight) + .backgroundColor(0xFFFFFF) + Column(){ + this.child() + } + .width('100%') + .layoutWeight(1) + } + .width('100%') + .height('100%') + } +} + +// Taro Hybrid的布局 +@Entry +@Component +struct TaroHybridPage { + build() { + Stack(){ + SafeArea(){ + TaroHybrid() + } + } + } +} +``` + +**全屏模式下的导航条的避让:** 通过鸿蒙Web组件支持安全边界,给H5的底部bar设置margin +```css +padding-bottom: env( safe-area-inset-bottom); +``` + +`注意:` 支持安全区域的鸿蒙单内核系统暂未发布 + + +### 访问控制权限二次弹窗指导 +@华为-漆灿 + +### 性能优化:同步调用走缓存 +taro的NativeApi,是taro暴露给鸿蒙实现的原生方法。使得Taro具备调用原生的能力。 + +对于NativeApi中的同步方法,每次调用都要执行原生代码逻辑,频繁调用必然导致系统开销增加和耗时增加。所以taro内部对同步方法增加了缓存机制。比如"getSystemSetting"、"getWindowInfo"等。 +如果应用层想扩展,对额外的同步方法使用缓存机制以提高执行效率,则可以通过如下步骤扩展使用缓存的方法: + +#### 明确使用缓存的方法名 +``` +let list = new ArrayList(); +list.add("youMethodName1") +list.add("youMethodName2") + +TaroHybridManager.setCacheSyncMethods(list) +``` + +list是一个方法名的列表,用于标识哪些NativeApi中的方法使用缓存。这里的youMethodName1、youMethodName2为要使用缓存的方法名。 + +#### 监听数据变化更新缓存 +同步方法使用缓存机制可以极大加快api响应速度,但是需要及时更新缓存中的数据。开发者需要在合适的时机监听数据变化,并通过如下方法更新数据 + + +``` +nativeUpdater.update(p: NativeApiPair, v?: any) +``` +举例说明: + +1. youMethodName1方法执行全量数据更新(无入参的情况): + +``` +nativeUpdater.update(new NativeApiPair("YouMethodName1",[])) +``` + + +2. youMethodName1方法执行全量数据更新(有入参的情况): + +``` +nativeUpdater.update(new NativeApiPair("youMethodName1",["param1",123])) +``` +3. youMethodName1方法执行局部数据更新(仅更新其中某个字段:myField): + +``` +nativeUpdater.update(new NativeApiPair("youMethodName1",["param1",123],"myField"),"newData") +``` +#### 注意 +1. 使用缓存机制的方法,必须是同步方法。 +2. 使用缓存机制的方法,在数据发生变化时必须及时更新缓存,并且对数据发生变化的场景做全方位覆盖。否则会带来获取到的数据不是最新的问题。 + + +### Taro.request请求方式选择(原生/js) + +#### 一、Taro.request 原生实现(native桥接)代码位置:taro/packages/taro-platform-harmony-hybrid/src/api/apis/network/request/index.ts + +```typescript + +let task!: Taro.RequestTask + const result: ReturnType = new Promise((resolve, reject) => { + const upperMethod = method ? method.toUpperCase() : method + const taskID = native.request({ + url, + method: upperMethod, + ...otherOptions, + success: (res: any) => { + isFunction(success) && success(res) + isFunction(complete) && complete(res) + resolve(res) + }, + fail: (res: any) => { + isFunction(fail) && fail(res) + isFunction(complete) && complete(res) + reject(res) + }, + }) + task = judgeUseAxios ? taskID : NativeRequest.getRequestTask(taskID) + }) as any +``` + +#### 二、Taro.request js实现(axios)代码位置:taro/packages/taro-platform-harmony-hybrid/src/api/apis/request.ts + +```typescript + +this.httpRequest = axios.create({ + responseType: responseType || 'text', + headers: headers, + timeout: timeout || 2000, + }) + + // 请求拦截器 + this.httpRequest.interceptors.request.use( + (config) => { + if (config.enableCache === false) { + return config + } + // 处理缓存 + const cacheData = localStorage.getItem(config.url) + if (cacheData !== null) { + let result = cacheData + if (dataType === 'json') { + result = JSON.parse(cacheData) + } + source.cancel('cache has useful data!!') + return Promise.resolve({ result }) + } + return config + }, + (error) => { + console.error('error: ', error) + } + ) + + // 响应拦截器 + this.httpRequest.interceptors.response.use( + (response) => { + // 缓存数据 + if (response.config.enableCache === false) { + localStorage.setItem(response.config.url, JSON.stringify(response.data)) + } + callbackManager.headersReceived.trigger({ + header: response.headers + }) + return response + }, + (error) => { + console.error('error: ', error) + } + ) + + if (!object) { + console.error('request error: params illegal') + return + } + + let isFormUrlEncoded = false + for (const key in headers) { + if (key.toLowerCase() === 'content-type') { + if (headers[key].toLowerCase().includes('application/x-www-form-urlencoded')) { + isFormUrlEncoded = true + } + break + } + } + + // data为Object类型时,属性的值类型如果是number, request请求时信息会丢失. 故将data转成string类型进行规避 + if (data && (isFormUrlEncoded || ['GET', 'OPTIONS', 'DELETE', 'TRACE', 'CONNECT'].includes(method))) { + const dataArray = [] + for (const key in data) { + // @ts-ignore + dataArray.push(encodeURIComponent(key) + '=' + encodeURIComponent(data[key])) + } + data = dataArray.join('&') + } + + // header的属性的值类型如果是number, request请求时信息会丢失. 故将各个属性转成string类型 + if (headers) { + for (const key in headers) { + headers[key] = `${headers[key]}` + } + } + + this.httpRequest({ + method: method, + url: url, + CancelToken: source.token, + enableCache: enableCache || false, + }) + .then((response) => { + if (success && !this.abortFlag) { + let result = response.result + if (response.config.responseType === 'text') { + if (dataType === 'text') { + result = response.data + } else if (dataType === 'json') { + result = JSON.parse(response.data) + } else if (dataType === 'base64') { + const encodeData = encodeURIComponent(response.data) + result = btoa(encodeData) + } else if (dataType === 'arraybuffer') { + result = new TextEncoder().encode(response.data).buffer + } else { + console.error('Unsupported dataType!!') + } + } else if (response.config.responseType === 'arraybuffer') { + result = response.data + } else { + console.error('Unsupported dataType!!: ', response.config.responseType) + } + const res = { + data: result, + statusCode: response.status, + header: response.headers, + cookies: response.cookies ? [response.cookies] : [], + errMsg: 'request:ok', + } + this.result = res + success(res) + } + }) + .catch((err) => { + console.error('request error: ' + JSON.stringify(err)) + if (fail && !this.abortFlag) { + // eslint-disable-next-line no-console + const res = { errMsg: errMsgMap.has(err.code) ? errMsgMap.get(err.code) : `${JSON.stringify(err)}` } + this.result = res + fail(res) + } + }) + .finally(() => { + if (complete && !this.abortFlag) { + complete(this.result) + } + if (this.httpRequest) { + source.cancel('requestTask cancelled by the user!') + } + }) +``` + +#### 三、原生、js方式转换实现(proxy代理)代码位置:taro/packages/taro-platform-harmony-hybrid/src/api/apis/NativeApi.ts + +```typescript + +class HybridProxy { + private readonly useAxios: boolean + private readonly useOsChannel: boolean + private readonly cacheProxy: any + private readonly requestApi = 'request' + + constructor (useAxios: boolean, useOsChannel: boolean, nativeApi: NativeApi) { + this.useAxios = useAxios + this.useOsChannel = useOsChannel + this.cacheProxy = new Proxy(nativeApi, new CacheStorageProxy(nativeApi)) + } + + get (_target: any, prop: string) { + return (...args: any) => { + if (this.useAxios && prop === this.requestApi) { + judgeUseAxios = this.useAxios + // @ts-ignore + return new RequestTask(...args) + } + if (this.useOsChannel && osChannelApi.hasOwnProperty(prop)) { + return osChannelApi[prop](...args) + } + return this.cacheProxy[prop](...args) + } + } +} + +const nativeApi = new NativeApi() +const native = new Proxy(nativeApi, new HybridProxy(false, false, nativeApi)) // 第一个false是默认走jsb,true是走纯js, 第二个false是不走osChannel +export default native +``` +#### 注意 +1、转换需要手动修改 const native = new Proxy(nativeApi, new HybridProxy(false, false, nativeApi)) 中 new HybridProxy的第一个参数,改为false为原生实现,若改为true,则为js实现。 + +### 同层渲染 +预计后续530支持 + +`@hybrid/web-container` 提供 `sameLayerManager.registerNativeComponentBuilders` 方法来注册同层渲染组件。 + +**参数说明:** + +| 参数名称 | 类型 | 描述 | 必填 | +|---------------|----------------------------------------------------|----------------------|---------------------| +| componentName | string | 对应embed标签type属性,去掉 `native/` 前缀 | 是 | +| builder | (...args: ESObject[]) => void | 使用 `@Builder` 注解的原生组件builder函数 | 是 | + + +**使用方法:** +- 壳工程中,注册自定义组件: + +```typescript +import { sameLayerManager } from '@hybrid/web-container' + +export default class EntryAbility extends UIAbility { + onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) { + sameLayerManager.registerNativeComponentBuilders('hos-video', NativeVideoBuilder) + } + // ... +} + +``` +- Video 自定义组件实现参考 + +```typescript +import { sameLayerManager } from '@hybrid/web-container'; + +@Observed +export class VideoParams { + width: number // 组件宽度 + height: number // 组件高度 + componentId: string // 组件ID + src: string // 要播放视频的资源地址 + controls?: boolean // 是否显示播放控件 + muted?: boolean // 是否静音 + loop?: boolean + autoPlay?: boolean + onPlay?: (res?: string) => void + onPause?: (res?: string) => void + + constructor() { + this.width = 0; + this.height = 0; + this.componentId = ''; + this.src = ''; + } +} + +@Component +export struct NativeVideo { + @ObjectLink params: VideoParams + videoController: VideoController = new VideoController(); + + build() { + Column() { + Video({ + src: this.params.src, + controller: this.videoController + }) + .objectFit(ImageFit.Fill) + .autoPlay(this.params.autoPlay ?? true) + .muted(this.params.muted ?? false) + .loop(this.params.loop ?? false) + .controls(this.params.controls ?? true) + .onClick((event) => { + console.log('NativeEmbed video onClick.') + }) + .onStart(() => { + console.log('NativeEmbed video onPlay.', this.params.height) + const callJsOnPlay: Function | undefined = (sameLayerManager.getSameLayerArgs(this.params.componentId) as VideoParams)?.onPlay + callJsOnPlay && callJsOnPlay('Native Video playing.') + }) + .onPause(() => { + console.log('NativeEmbed video onPause.') + const callJsOnPause: Function | undefined = (sameLayerManager.getSameLayerArgs(this.params.componentId) as VideoParams)?.onPause + callJsOnPause && callJsOnPause('Native Video paused.') + }) + } + .width(this.params.width) + .height(this.params.height) + } +} + +@Builder +export function NativeVideoBuilder(params: VideoParams) { + NativeVideo({ params: params }) + .backgroundColor(Color.Green) +} + +``` + +- 前端代码中,使用 `react` 框架实现的代码如下: +```typescript +import React from 'react' +import classNames from 'classnames' + +import './index.scss' + +let videoId = 0 + +interface IProps extends React.HTMLAttributes { + src: string + controls?: boolean + autoplay?: boolean + loop?: boolean + muted?: boolean + onPlay?: (res: any) => void + onPause?: (res: any) => void +} + +export default class HosVideo extends React.Component { + private componentId: string + + constructor (props: IProps | Readonly) { + super(props) + this.componentId = `video_${videoId++}` + } + + componentDidMount () { + this.transferVideoProps() + } + + componentDidUpdate (_prevProps: IProps, _prevState: any) { + // 组件更新时重新传输数据 + this.transferVideoProps() + } + + transferVideoProps () { + // 同层渲染video组件数据 + const { + src, + controls, + autoplay, + loop, + muted, + onPlay, + onPause + } = this.props + + const properties = { + componentId: this.componentId, + src: src, + controls: controls, + autoPlay: autoplay, + loop: loop, + muted: muted, + onPlay: onPlay, + onPause: onPause + } + + // @ts-ignore 调用JSB方法传递原生组件数据 + window.JSBridge && window.JSBridge.transferSameLayerArgs(properties) + } + + render () { + const { + style, + className + } = this.props + + return ( +
+ +
+ ) + } +} +``` + + +## FAQ + +### 原生导航条影响Fixed布局 + +**原因:** Web容器与原生导航栏之间是层叠布局,为了不影响H5页面的内容展示,Taro Harmony给每个Page设置了一个padding-top值,其值来自原生注入的一个变量 + +```typescript +// Web组件加载时,原生会给js侧注入的代码: +var navigationHeight = ${this.navigationBarHeight}; + +// 使用方法 +window.navigationHeight +``` + +当存在原生导航栏时,fixed布局需要添加一个top值,其值为window.navigationHeight + +[Web调试devtools配置]: https://gitee.com/openharmony/docs/blob/master/zh-cn/application-dev/web/web-debugging-with-devtools.md diff --git a/versioned_sidebars/version-3.x-sidebars.json b/versioned_sidebars/version-3.x-sidebars.json index 444bf5a81b23..662f363554f0 100644 --- a/versioned_sidebars/version-3.x-sidebars.json +++ b/versioned_sidebars/version-3.x-sidebars.json @@ -75,7 +75,8 @@ "type": "category", "items": ["react-native", "react-native-remind"] }, - "harmony" + "harmony", + "harmony-hybrid/index" ], "进阶指南": [ "config-detail",