大家好,我是坚果,今天我们一起来看一下如意甘肃这一个元服务的实现细节。

开始之前,给大家介绍一下这节课的内容

为了帮助您快速了解元服务工程目录的构成,并熟悉元服务开发流程,我们将构建一个简单的元服务(基于HarmonyOS主推的ArkTS语言及Stage模型进行开发)

概述

在万物互联时代,人均持有设备量不断攀升,设备和场景的多样性,使应用开发变得更加复杂、应用入口更加多样。在此背景下,应用提供方和用户迫切需要一种新的服务提供方式,使应用开发更简单、服务(如听音乐、打车等)的获取和使用更便捷。为此,HarmonyOS除支持传统方式的需要安装的应用(以下简称传统应用)外,还支持更加方便快捷的免安装的应用(即元服务)

元服务(原名为原子化服务)是HarmonyOS提供的一种面向未来的服务提供方式,是有独立入口的(用户可通过点击服务卡片打开元服务)、免安装的(无需显式安装,由系统程序框架后台安装后即可使用)用户应用程序形态。

元服务基于HarmonyOS API开发,支持运行在1+8+N设备上,供用户在合适的场景、合适的设备上便捷使用。元服务相对于传统应用形态更加轻量,同时提供更丰富的入口、更精准的分发。

元服务特征

元服务区别于传统应用,具备如下特征。

  • 服务直达
    • 元服务支持免安装使用。
    • 服务卡片:支持用户无需打开元服务便可获取服务内重要信息的展示和动态变化,如天气、关键事务备忘、热点新闻列表等。
  • 跨设备
    • 元服务支持运行在1+8+N设备上,如手机、平板、2in1、智慧屏等设备。
    • 支持跨设备分享:用户可分享元服务给好友,好友确认后打开分享的服务。

开发准备

  1. 安装最新版DevEco Studio
  2. 完成DevEco Studio的安装和开发环境配置。"大前端之旅"。

创建元服务工程

1.若首次打开DevEco Studio,请选择Create Project开始创建一个新工程。如果已经打开了一个工程,请在菜单栏选择File > New > Create Project来创建一个新工程。选择Atomic Service元服务开发,选择“Empty Ability”模板,单击Next进行下一步配置。

image-20240221124346639

2.进入配置工程界面,修改“Project name”,Compile SDK选择API9,其他参数保持默认设置即可。

3.单击Finish,工具会自动生成示例代码和相关资源,等待工程创建完成。

目录结构

元服务工程目录结构如下。

  • AppScope > app.json5:元服务的全局配置信息。

  • entry

    :HarmonyOS工程模块,编译构建生成一个HAP。

    • src > main > ets:用于存放ArkTS源码。
    • src > main > ets > entryability:元服务的入口。
    • src > main > ets > pages:元服务包含的页面。
    • src > main > ets > widget > pages:元服务工程创建时,会自动生成一个服务卡片页面。
    • src > main > resources:用于存放元服务所用到的资源文件,如图形、多媒体、字符串、布局文件等。
    • src > main > resources > base > profile > form_config.json:元服务卡片配置文件。
    • src > main > module.json5:模块配置文件。主要包含HAP的配置信息、元服务在具体设备上的配置信息以及元服务的全局配置信息。具体的配置文件说明,详见module.json5。
    • build-profile.json5:当前的模块信息 、编译信息配置项,包括buildOption、targets配置等。
    • hvigorfile.ts:模块级编译构建任务脚本,开发者可以自定义相关任务和代码实现。
  • oh_modules:用于存放三方库依赖信息。

  • build-profile.json5:元服务级配置信息,包括签名signingConfigs、产品配置products等。其中products中可配置当前运行环境,默认为HarmonyOS。

  • hvigorfile.ts:元服务级编译构建任务脚本。

  • EntryCard:元服务卡片快照图片存放目录。

接下来我们就完成元服务的名称和图标的修改。

名称的修改。

图标的修改。

2.如意甘肃的视频介绍,这里面主要包含video组件的使用。

本节课,我们主要完成video组件的使用,来向大家展示甘肃的概况。

视频

视频


视频组件使用Video组件来播放,另外,因为需要控制播放和暂停,所以需要另外添加两个Image组件来显示播放和暂停按钮。

Video和Image组件通过Stack容器来封装布局。

伪代码如下
Stack() {
    Video()
    Image()
    Image()
}
.width('100%')
Video播放涉及到一下变量的定义
@State isPlay: boolean = false;
@State playTime: number = 0;
private controller: VideoController = new VideoController();
Video组件源码如下
Video({
    src: 'http://video_src', // 1
    previewUri: 'http://preview_image',
    controller: this.controller
})
    .controls(false) // 2
    .onClick(() => {
      this.isPlay = !this.isPlay // 3
      if (this.isPlay) {
        this.controller.start()
      } else {
        this.controller.pause()
      }
    })
    .onUpdate((e) => {
      this.playTime = e.time // 4
    })
    .onFinish(() => {
      this.isPlay = false // 5
    })
  1. 设置Video的属性。src为视频流地址,previewUri为未开始播放前,用于预览的占位图片,controller为之前定义的变量,用于播放器的控制,包括开始,暂停,停止,获取播放时间,进出全屏模式等。
  2. 隐藏Video自带的控制UI。
  3. 点击以后反转isPlay布尔值。随后布尔值如果为真,就播放视频,否则暂停视频。
  4. 记录调用this.controller.start()之后的播放时长。后面会用到。
  5. 如果进入onFinish回调,则isPlay置为假。
播放图片Image组件源码如下
Image("https://start_image_src")
    .visibility(this.isPlay ? Visibility.None : Visibility.Visible) // 1
    .onClick(() => {
      this.isPlay = !this.isPlay // 2
      this.controller.start()
    })
  1. 控制显隐,如果视频正在播放,则不显示播放的图片。可见性设为None,不占用布局空间。
  2. 点击以后反转isPlay布尔值。并开始播放视频。
暂停图片Image的源码如下,于播放图片相似
Image("https://pause_image_src")
    .visibility(this.isPlay && this.playTime <= 5 ? Visibility.Visible : Visibility.None) // 1
    .onClick(() => {
      this.isPlay = !this.isPlay // 2
      this.controller.pause()
    })
  1. 控制显隐,如果视频正在播放,且播放时长小于5秒,则显示暂停的图片。否则不显示,可见性设为None,不占用布局空间。。
  2. 点击以后反转isPlay布尔值。并暂停播放视频。

1. 视频

视频


视频组件使用Video组件来播放,另外,因为需要控制播放和暂停,所以需要另外添加两个Image组件来显示播放和暂停按钮。

Video和Image组件通过Stack容器来封装布局。

伪代码如下
Stack() {
    Video()
    Image()
    Image()
}
.width('100%')
Video播放涉及到以下变量的定义
@State isPlay: boolean = false;
@State playTime: number = 0;
private controller: VideoController = new VideoController();
Video组件源码如下
Video({
    src: 'http://video_src', // 1
    previewUri: 'http://preview_image',
    controller: this.controller
})
    .controls(false) // 2
    .onClick(() => {
      this.isPlay = !this.isPlay // 3
      if (this.isPlay) {
        this.controller.start()
      } else {
        this.controller.pause()
      }
    })
    .onUpdate((e) => {
      this.playTime = e.time // 4
    })
    .onFinish(() => {
      this.isPlay = false // 5
    })
  1. 设置Video的属性。src为视频流地址,previewUri为未开始播放前,用于预览的占位图片,controller为之前定义的变量,用于播放器的控制,包括开始,暂停,停止,获取播放时间,进出全屏模式等。
  2. 隐藏Video自带的控制UI。
  3. 点击以后反转isPlay布尔值。随后布尔值如果为真,就播放视频,否则暂停视频。
  4. 记录调用this.controller.start()之后的播放时长。后面会用到。
  5. 如果进入onFinish回调,则isPlay置为假。
播放图片Image组件源码如下
Image("https://start_image_src")
    .visibility(this.isPlay ? Visibility.None : Visibility.Visible) // 1
    .onClick(() => {
      this.isPlay = !this.isPlay // 2
      this.controller.start()
    })
  1. 控制显隐,如果视频正在播放,则不显示播放的图片。可见性设为None,不占用布局空间。
  2. 点击以后反转isPlay布尔值。并开始播放视频。
暂停图片Image的源码如下,于播放图片相似
Image("https://pause_image_src")
    .visibility(this.isPlay && this.playTime <= 5 ? Visibility.Visible : Visibility.None) // 1
    .onClick(() => {
      this.isPlay = !this.isPlay // 2
      this.controller.pause()
    })
  1. 控制显隐,如果视频正在播放,且播放时长小于5秒,则显示暂停的图片。否则不显示,可见性设为None,不占用布局空间。。
  2. 点击以后反转isPlay布尔值。并暂停播放视频。

2. 长文字

甘肃概况中,显示了一段长文字。默认给Text组件加了高度限制。下方有个“展开全文”提示,是一个Text组件,点击展开上面的长文字。

概况

长文字及展开全文的代码
Text() {
  Span("长文字")
}
.constraintSize({ maxHeight: this.isExpandGansugaikuang ? Infinity : 300 }) // 1
        
Text("展开全文")
  .visibility(this.isExpandGansugaikuang ? Visibility.None : Visibility.Visible) // 2
  .onClick(() => {
    this.isExpandGansugaikuang = true // 3
  })
  1. 长文字的高度用constraintSize来控制,通过一个布尔值isExpandGansugaikuang来判断是否展开,默认为假。如果展开的话,最大高度就是无限,这样可以让控件自己决定实际显示的高度。如果不展开,给最大高度一个固定值。
  2. 展开全文的文字提示控制显隐,一旦展开以后就不显示展开的提示了,也不提供收起功能。
  3. 点击展开提示后,设置布尔值isExpandGansugaikuang为真,上面的代码就会根据布尔值更新UI。

3. Tabs组件

“历史沿革”的模块于下方的“人口自然地理”同理,使用了Tabs组件

“历史沿革”文字这一行用了一张图片来显示,一个简单的布局,不做详细说明。

下方有三个tab按钮,按钮下方是长文字,用TabContent来封装。按钮和TabContent布局封装在组件Tabs里。这里讲解的源码主要对Tabs讲解,TabContent内的布局提供简化示例。

扩展阅读:实际在TabContent中,会针对文字的长度做一个判断,如果文字内容过长,会把文字嵌套在一个Scroll里面。至于为什么要区分?如果全部都放在Scroll里面,对开发工作不是更省力?
因为api9中对嵌套的Scroll会有操作上的问题,内层的Scroll操作完全会无视外层的Scroll,以至于内层Scroll触达边界时,内层的滑动手势无法传递到外层的Scroll,这样首页本身这个大的Scroll就无法自由地滑动。
这个不方便的情况在api10里面提供了一个很方便的属性nestedScroll。可以查看这篇官方文档

Tabs代码如下
Column() {
  Tabs({ barPosition: BarPosition.Start, controller: this.lishitabcontroller }) { // 1
    ForEach(this.viewModel.historyList, (item: DetailsModel, index: number) => { // 2
      TabContent() {
        Text("长文字")
      }.tabBar(this.historyTabBuilder(index, item.title)) // 3
    }, (item: DetailsModel) => item.id)
  }
  .vertical(false) // 4
  .barMode(BarMode.Fixed) // 5
  .onChange((index: number) => {
    this.currentIndex = index // 6
  })
}

@Builder
historyTabBuilder(index: number, name: string) {
  Column() {
    Text(name)
      .fontColor(this.currentIndex === index ? this.unselectedColor : this.fontColor) // 7
      .fontWeight(this.currentIndex === index ? 500 : 400)
      .backgroundColor(this.currentIndex === index ? this.themeColor : this.unselectedColor)
  }
}
  1. barPosition设置为BarPosition.Start,因为tab按钮在前,对应的tab content在后。
  2. 用ForEach来循环设置TabContent内容。这里不需要用LazyForEach,实际的运行环境中已知不会有大量的数组数据,用ForEach减轻开发难度。
  3. TabContent内布局完成之后,设置对应的tab bar按钮布局。会在下文解释该布局。
  4. Tabs设置为上下布局,vertical置为false。
  5. Tabs提供了可滑动属性,如果tab条目过多,超过屏幕范围,可以滑动来显示更多内容。当前业务场景中不需要滑动,设置为BarMode.Fixed。
  6. TabContent可以左右滑动,以方便地浏览相邻的不同tab页。onChange回调会在左右滑动时被调用,传入当前页的序号。此处需要在回调方法中记录当前tab页的序号到本界面的一个成员变量中 currentIndex
  7. 这个是上文中提到的tab bar按钮布局。根据记录的 currentIndex 来控制按钮的高亮显示。因为“历史沿革”和“人口自然地理”布局相同,tab builder原则上可以复用,
    但由于当前api9的限制,原本打算把不同的2个@State index传参进来,这样两个Tabs组件都能复用同一个tab builder。但传入的是一个@State变量,
    而$$语法在api9只支持Refresh组件。所以当前项目中,这个tab builder复制了两份一模一样的代码,非常不优雅,但也很无奈。
    可喜的是在api10的文档中$$增加了很多支持的组件,其中就包括Tabs。

4. Swiper组件

“畅游甘肃”模块中使用了Swiper组件,这个组件比较简单,传入一个 SwiperController 变量来控制Swiper的一些行为。当前业务中没有特殊要求,但这个变量是必须的。

代码如下
Swiper(this.swiperController) {
  ForEach(this.viewModel.attractionsList, (item: DetailsModel) => { // 1
    Image(item.cover)
      .onClick(() => {
      router.pushUrl({ // 2
        url: "pages/DetailsPage/DetailsPage"
      })
    })
  }, (item: DetailsModel) => JSON.stringify(item))
}
  1. 用ForEach循环布局图片
  2. 点击图片跳转详情页

5. 首页其他

首页中其他的布局都是基本流式布局,涉及到Row、Column等,不做详细解读。

6. 列表页

列表页使用了Grid组件做布局

列表页

代码如下
  @Prop ListData: Array<DetailsModel>
  private dataSource = new ListPageContentDataSource(this.ListData)

  build() {
    Column() {
      Grid() {
        LazyForEach(this.dataSource, (item: DetailsModel) => { // 1
          GridItem() { // 2
            Column() { // 3
              Image(item.cover)
                .width(92)
                .height(92)
              Text(item.title)
            }
          }
          .onClick(() => {
            router.pushUrl({ // 4
              url: "pages/DetailsPage/DetailsPage"
            })
          })
        }, (item: DetailsModel) => JSON.stringify(item))
      }
      .columnsGap(14) // 5
      .rowsGap(16)
    }
  }
  1. 本页的数组元素可能会有很多,所以用LazyForEach来提升性能。相比ForEach会增加一些工作量。具体实现见下文。
  2. Grid内嵌套GridItem 来给每一个元素布局。
  3. GridItem只能有一个子元素,所以整个内容布局用一个Column来包含。
  4. 点击某个元素进入详情页。
  5. 设置 columnsGaprowsGap 来控制各个元素之间的间距。

ListPageContentDataSource 需要在界面struct之外声明一个 IDataSource 的实现。

实现代码如下
class ListPageContentDataSource implements IDataSource {
  listData: Array<DetailsModel>

  constructor(listData: Array<DetailsModel>) { // 1
    this.listData = listData
  }

  totalCount(): number { // 2
    return this.listData.length
  }

  getData(index: number) { // 3
    return this.listData[index]
  }

  registerDataChangeListener(listener: DataChangeListener) { // 4

  }

  unregisterDataChangeListener(listener: DataChangeListener) {

  }
}
  1. 构造函数里传入数组并保存在成员变量 listData 里,以备之后的操作。
  2. 在这里返回数据的数量,取 listData 的长度。
  3. 在这里返回具体数据,方法传入 index ,从 listData 里取第index个数据返回。
  4. registerDataChangeListenerunregisterDataChangeListener 为必须实现的方法。这里没有用到,可以留空,但必须实现这两个方法。

6. 详情页

详情页最外层使用了一个Scroll来包含所有的内容。内容整体为一个Column布局,比较简单。

详情页

代码如下
dateFormat: Intl.DateTimeFormat = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' }) // 1
        
build() {
  Column() {
    Scroll() {
      Column() { // 2
        Text(this.item.title)
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .margin({ bottom: 10 })
        Text(this.item.createTime === undefined ? "" : this.dateFormat.format(new Date(parseInt(this.item.createTime)))) // 3
          .margin({ bottom: 20 })
        Image(this.item.cover)
          .width("100%")
          .margin({ bottom: 20 })
        Text(this.item.desc)
          .fontSize(16)
      }
    }
  }
}
  1. Intl.DateTimeFormat可用于格式化日期。下文会用到。
  2. Scroll的下一层必须用一个容器把所有布局包进去,不然会出现无法滑动的情况。
  3. 使用之前实例化的 this.dateFormat 来把 Date 实例转为可读的文本。 this.dateFormat.format() 传入Date实例。Date实例可以用时间戳的数字来生成。

本章对本App的部分架构设计进行一些解读。

1. 首页UI与数据分离

本App涉及到一些云侧的SDK(agconnect)使用,用于获取数据,数据的获取和UI显示需要解耦。

首页定义了

@State viewModel: IndexViewModelInterface = new IndexViewModel()

用于管理数据的获取。定义viewModel的时候使用了一个接口 IndexViewModelInterface ,赋值的时候使用了 IndexViewModel 具体实现。IndexViewModel 继承自 IndexViewModelInterface

这里原先设计的是用 @Prop viewModel: IndexViewModelInterface 然后在外部注入viewModel的具体实现,以此来使首页代码完全摆脱agconnect,使其可以在Previewer里面可以预览。后来发现可能某些特性,IDE还不支持,于是作罢。这个未成功的设计在下文的详情页和列表页实践成功。

IndexViewModelInterface 的定义如下

@Observed
export abstract class IndexViewModelInterface {
  abstract historyList: Array<DetailsModel>
  abstract specialtyList: Array<DetailsModel>
  abstract attractionsList: Array<DetailsModel>
  abstract foodList: Array<DetailsModel>
  abstract celebrityList: Array<DetailsModel>
  abstract dramaList: Array<DetailsModel>
  abstract folkCustomList: Array<DetailsModel>
  abstract gansuDetails: any
  abstract populationPhysicalPeographyList: Array<DetailsModel>

  abstract refreshData()
}

IndexViewModel 实现中给成员赋默认值为空数组,例如:

historyList: DetailsModel[] = []

然后实现 refreshData() 方法。

  refreshData() {
    this.refreshHistoryList()
    this.refreshSpecialtyList()
    this.refreshAttractionsList()
    this.refreshFoodList()
    this.refreshCelebrityList()
    this.refreshDramaList()
    this.refreshFolkCustomList()
    this.refreshGansuDetails()
    this.refreshPopulationPhysicalList()
  }

方法实现中获取首页各个模块的数据。每个获取数据的方法都是异步请求。在获取到数据之后,会在回调方法里把数据赋值给成员变量,例如:

this.historyList = res.getValue().data.records as DetailsModel[];

这样, this.historyList 被更新,因为整个class被观察 @Observed class,所以它的成员的变化会触发监听者的更新。上文中提到了首页的viewModel成员

@State viewModel: IndexViewModelInterface = new IndexViewModel()

因此在首页UI中,用到viewModel中数据的UI,都会触发UI更新。

2. 首页跳转列表页

上文中在view model中获取到了数据,并引发首页UI更新。这些数据同样可以用于跳转到列表页的传值。
以下代码定义了一张图片的点击跳转事件。在 router.pushUrl() 方法中传入 this.viewModel.folkCustomList 。记住数据的key为 name ,在列表页获取数据时要用到。

Image("民俗图片地址")
    .onClick(() => {
      router.pushUrl({
        url: "pages/ListPage/ListPage",
        params: {
          name: this.viewModel.folkCustomList
        }
      })
    })

列表页中定义成员

ListData: Array<DetailsModel> = router.getParams()?.["name"]

注意此处通过 router.getParams() 的下标方法获取 router.pushUrl() 传进来的值。记得刚才设置的key为 name
至此 ListData 中的数据就可以用于列表页的数据显示。

3. 页面的可预览架构

上文中定义的

ListData: Array<DetailsModel>

在预览的时候,如果使用agconnect的真实数据,对Previewer来说没有必要,硬件开销过大,且本身Previewer就不支持。Previewer主要任务是预览UI布局,对数据来说,使用硬编码的假数据就足够了。
所以这里在架构上做了一个解耦的设计。
上文中的首页跳转到ListPage,ListPage的源码其实是这样的

@Entry
@Component
struct ListPage {
  ListData: Array<DetailsModel> = router.getParams()?.["name"]

  build() {
    Column() {
      ListPageContent({ ListData: this.ListData })
    }
  }
}

ListPageContent 中才是真正的UI布局代码。这样在实际运行的时候,首页通过viewModel从agconnect获取网络数据,通过router传值,ListPage中从router取值,传入 ListPageContent 中显示。

另外,还有一个 ListPagePreviewer 类,里面也包了 ListPageContent。在 ListPagePreviewer 中也有一个成员变量

ListData: Array<DetailsModel>

对这个变量进行硬编码赋值,然后传入 ListPageContent 中显示。于是,我们用Previewer对 ListPagePreviewer 进行预览,可以方便地调试UI布局。

@Preview
@Component
struct ListPagePreviewer {
  ListData: Array<DetailsModel> = [
    {
      id: "1",
      title: "嘉峪关文物景区",
      createTime: "1705849931",
      desc: "嘉峪关文物景区简介嘉峪关文物景区简介嘉峪关文物景区简介嘉峪关文物景区简介",
      cover: $r("app.media.icon")
    },
    {
      id: "2",
      title: "嘉峪关文物景区",
      createTime: "1705849931",
      desc: "嘉峪关文物景区简介",
      cover: $r("app.media.icon")
    }
  ]

  build() {
    ListPageContent({ ListData: this.ListData })
  }
}

1. 概述

本App提供两种尺寸的卡片,2x2和2x4。

卡片

在form_config.json中声明两个卡片。代码如下,省略无关的其他属性:

{
  "forms": [
    {
      "defaultDimension": "2*2",
      "src": "./ets/widget/widget.ets",
      "supportDimensions": [
        "2*2"
      ]
    },
    {
      "defaultDimension": "2*4",
      "src": "./ets/widget/widget2.ets",
      "supportDimensions": [
        "2*4"
      ]
    }
  ]
}

2. 2x2卡片

2x2卡片的实现非常简单,一个Column的流式布局,里面若干个Text即可。最外层的Column添加onClick事件,调用 postCardAction() ,执行跳转首页的操作。

代码如下
postCardAction(this, {
    action: "router",
    abilityName: "EntryAbility"
  });

3. 2x4卡片

2x4卡片有四个入口,第一个跳转首页,和2x2卡片一样。

第二、三、四个需要跳转详情页。 postCardAction() 的调用需要传一些参数。参考这篇官方文档

代码如下
postCardAction(this, {
    action: "router",
    abilityName: "EntryAbility",
    params: {
      targetPage: page
    }
  });

action声明卡片的行为为路由导航,跳转到abilityName声明的Ability,在 params 里面传值。
传值的键名可以任意填,这里是 targetPage 。传入的值 page ,是自定义的一个枚举值。

export enum CardRoutePageSelection {
  MAIN, // 首页
  LZNRM, // 兰州牛肉面
  TECHAN, // 特产
  MINSU // 民俗
}

枚举值需要在卡片的文件 Widget2.ets 中声明。因为卡片不支持import,所以这个枚举定义在其他文件中用到的话,让其他的文件 import Widget2.ets

以兰州牛肉面举例,当点击兰州牛肉面的图片时, postCardAction() 的参数里带了 targetPage: CardRoutePageSelection.LZNRM

接着我们看到 EntryAbility 中的回调方法 onCreate()onNewWant() 。当元服务冷启动时,导航到 EntryAbility 会走 onCreate(),否则会走 onNewWant() 。所以需要在两个回调方法中都做数据接收的处理,此处用一个同意的方法来处理。

代码如下
  routeFromCard(want: Want) {
    if (want.parameters?.params !== undefined) {
      let params: Record<string, number> = JSON.parse(want.parameters?.params.toString()); // 1
      console.info("onCreate router targetPage:" + params.targetPage);
      selectPage = params.targetPage // 2
    }
  }
  1. params 定义为 Record<string, number> 因为传入的枚举值默认为number类型。
  2. 通过JSON转换后得到的 params 中取得 targetPage 参数。保存到一个成员变量 selectPage

onWindowStageCreate() 回调中判断 selectPage 的值,跳转到相应的界面。

    if (selectPage !== undefined) { // 1
      switch (selectPage) {
        case CardRoutePageSelection.MAIN: {
          pageType = 'pages/index'
          break
        }
        default: {
          pageType = 'pages/DetailsPage/DetailsPage' // 2
          break
        }
      }
    } else {
      pageType = 'pages/index'
    }
    if (currentWindowStage === null) {
      currentWindowStage = windowStage;
    }
    const para: Record<string, number> = { // 3
      'page': selectPage
    };
    const storage: LocalStorage = new LocalStorage(para);
    windowStage.loadContent(pageType, storage, (err, data) => { }); // 4
  1. 如果成员变量 selectPage 已定义,则说明这个元服务是从卡片跳转过来的。否则为正常方式打开,跳转默认的 pages/index
  2. CardRoutePageSelection 枚举中除了 MAIN ,其他都是导航到详情页。详情页中还需要判断具体是哪一个内容,所以枚举值还得继续往页面里传。
  3. 这里又定义了一个 para: Record<string, number> 和接收卡片跳转参数相似。记住传值的key为 page
  4. 项目生成的默认代码中,这一行是加载首页内容的。在这里传入 storage 参数,这是一个 LocalStorage 实例,该参数通过前面声明的 para 来实例化。
    到这里, CardRoutePageSelection 这个枚举值,就从卡片跳转时调用 postCardAction() ,传到 onCreate()onNewWant() ,通过Want获取,暂存在成员变量中,在 onWindowStageCreate() 中通过 LocalStorage 传入对应的页面中。

接下来需要在 DetailsPage 页面中获取 CardRoutePageSelection 的枚举值。

DetailsPage 的UI布局和 ListPage 类似,也是可以用假数据预览的架构设计,DetailsPage 内嵌 DetailsPageContent ,在 DetailsPageContent 中布局UI。但它多了对传入参数的处理。

DetailsPage 定义了这些成员变量
  @State item: DetailsModel = router.getParams()?.["name"] // 1
  @State @Watch("onViewModelUpdated") indexViewModel: IndexViewModel = new IndexViewModel() // 2
  @LocalStorageLink('page') page: CardRoutePageSelection = CardRoutePageSelection.MAIN // 3
  1. 这个item默认是取从首页或者列表页跳转过来的传值。
  2. indexViewModel 进行监听, onViewModelUpdated() 中处理数据更新。
  3. 在这里获取从 EntryAbility 中传入的枚举。通过键名 page 来获取。

DetailsPage 声明的时候需要取得 LocalStorage 数据。并传入 @Entry 注解。

let localStorage = LocalStorage.GetShared()
@Entry(localStorage)
@Component
struct DetailsPage {
  build() {
    // UI布局
  }
}

至此,卡片中的 CardRoutePageSelection 枚举值终于传到了 DetailsPage 中。接下来,在 aboutToAppear() 中判断获取到的枚举值。

if (this.item === undefined) {
      switch (this.page) {
        case CardRoutePageSelection.LZNRM: {
          this.indexViewModel.refreshFoodList() // 1
          break
        }
      }
    }
  1. 如前文中提到的, this.indexViewModel.refreshFoodList() 会更新view model中的 foodList

因为在声明成员变量indexViewModel时加了@Watch,所以indexViewModel的成员变量的更新会触发 onViewModelUpdated()

onViewModelUpdated() {
    switch (this.page) {
      case CardRoutePageSelection.LZNRM: {
        const nrmModel = this.indexViewModel.foodList.filter((model) => { // 1
          return model.title === "兰州牛肉面"
        })
        if (nrmModel.length > 0) {
          this.item = nrmModel[0]
        } else {
          this.item = this.indexViewModel.foodList[0] // 2
        }
        break
      }
    }
  }
  1. foodList 进行筛选,把兰州牛肉面的数据筛选出来,赋值给成员变量 item ,因为 item@State 监听,所以会传到 DetailsPageContent({ item: this.item }) 中触发 DetailsPageContent 的UI更新。
  2. 如果网络获取的数据中没有兰州牛肉面,那就取食物数据的第0个数据展示。
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐