很多朋友都把自己的手机升级为了鸿蒙系统。如果你手头有两部或两部以上鸿蒙系统的手机,就可以尽情地体验鸿蒙的分布式能力了。如果你手头只有一部鸿蒙系统的手机,不知道你有没有感知到:与升级前相比,在用户体验上有哪些变化呢?细心体验就会发现,最大的变化非”卡片”莫属了!卡片的功能非常强大,用户无需打开应用,就可以从卡片中获取应用相关的动态信息,而且还可以与卡片进行交互。最重要的是,在未来,卡片很可能会成为一个巨大的流量入口,从而成为第三方应用厮杀的阵地!
为了帮助大家更好地学习,我详细总结了鸿蒙卡片开发的核心技术精要,包括以下几部分:
一、 什么是卡片
二、 卡片的数量和尺寸
三、 卡片与原子化服务
四、 卡片的整体框架
五、 使用JS开发卡片
5.1 使用模板创建卡片
5.2 卡片的初始化
5.3 卡片的定点/定时刷新
5.4 卡片的跳转事件
5.5 卡片的消息事件
六、 使用Java开发卡片
6.1 使用模板创建卡片
6.2 卡片的初始化
6.3 卡片的定点/定时刷新
6.4 卡片的事件
七、开发卡片到底该使用JS还是使用Java
一、什么是卡片
手机升级为鸿蒙系统之后,在某些应用的图标下方显示了一条横线,如下图所示:
凡是图标下方显示一条横线的应用,都可以在桌面上添加对应的卡片。
以“新浪新闻”这个应用为例,用手指按下图标的同时往上滑,就会弹出该应用的默认卡片,如下图所示:
点击卡片右上角的图钉,就将卡片固定在了桌面上,如下图所示:
卡片中的新闻会动态刷新。这样,用户无需打开应用,就可以从卡片中获取应用相关的动态信息。点击卡片中的某一条新闻,就跳转到了应用的相关页面,如下图所示:
这个卡片设计得不是很好,最好是点击卡片中的某一条新闻,能跳转到应用内该条新闻对应的详情页面。
再以“音乐”这个应用为例,用手指按下图标的同时往上滑,就会弹出该应用的默认卡片,点击卡片右上角的图钉,就将卡片固定在了桌面上,如下图所示:
点击卡片中的按钮,可以开始播放音乐和暂停播放音乐。这样,通过与卡片进行交互,用户无需打开应用,就可以实现应用内的部分操作。
通过这两个例子,我们看到:卡片是应用内页面的展现形式,将页面的重要信息或者操作前置到卡片上,以达到服务直达、减少体验层级的目的。
二、卡片的数量和尺寸
我们已经知道了:卡片是应用内页面的展现形式,也就是应用内Page Ability的展现形式。一个应用内包含1~N个Page Ability,我们可以在config.json中为每个Page Ability配置0~16个卡片,而配置的每个卡片可以有1~4个尺寸,因此,每个Page Ability对应的卡片数是0~64。
如何查看一个应用的所有卡片呢?以“日历”这个应用为例,在桌面上长按其图标,在弹出的菜单中点击”服务卡片”,就显示出了日历这个应用的所有卡片,如下图所示:
可以通过上下滑动在卡片之间进行切换。
在所有卡片中,只有一个卡片下方的按钮显示为”已设为上滑卡片”,其它卡片下方的按钮都显示为”设为上滑卡片”。当某个卡片被设为上滑卡片之后,在桌面上用手指按下应用图标的同时往上滑,弹出的默认卡片就是该上滑卡片。比如将最后一个月视图的卡片设为上滑卡片,如下图所示:
点击下方的按钮”设为上滑卡片”,该卡片就会等待用户将其钉在桌面上,先点击桌面的空白处将其取消,然后用手指按下应用图标的同时往上滑,弹出的默认卡片就是月视图的卡片了。
再次查看“日历”这个应用的所有卡片。对于任意一个卡片,都可以点击下方的按钮”添加到桌面”,将其添加到桌面上。对于同一个卡片,用户可以在桌面上重复添加多个实例,如下图所示:
长按桌面上的某个卡片实例,在弹出的菜单中可以移除该卡片,也可以查看其对应应用的所有卡片。此外,用手指按下应用图标的同时往上滑,然后长按弹出的默认卡片,也可以查看其对应应用的所有卡片。
无论一个应用有多少个卡片,卡片只有4种尺寸,分别是:1×2的微尺寸、 2×2的小尺寸、2×4的中尺寸、4×4的大尺寸。以“相机”这个应用为例,如下图所示:
对于1×2的微尺寸,会占据1行2列;对于2×2的小尺寸,会占据2行2列;对于2×4的中尺寸,会占据2行4列。同理,对于4×4的大尺寸,会占据4行4列。任何一个卡片的尺寸都属于这4种尺寸中的其中一种。
三、卡片与原子化服务
与传统的需要安装的应用相比,原子化服务是应用的另外一种形态,他是可以提供特定功能的、免安装的、有独立入口的应用形态。这里有一个非常重要的关键词:免安装。原子化服务是鸿蒙系统提供的一种面向未来的服务提供方式,他非常非常的重要,希望大家引起足够的重视。
给大家举个例子就明白什么是原子化服务了,如下图所示:
对于某个传统方式的、需要安装的”购物应用T”, 在按照原子化服务理念调整设计后,可以将”商品浏览”独立拆分为一个原子化服务A,将”购物车”独立拆分为一个原子化服务B,将”支付”独立拆分为一个原子化服务C,每个原子化服务都提供了特定的功能,而且是免安装的。用户在用到某个原子化服务时,再按需进行安装,系统程序框架会在后台自动地从原子化服务平台进行下载和安装,而无需用户显式地手动安装。
1个原子化服务完成1个特定的便捷服务。原子化服务由1个或多个HAP包组成,1个HAP包对应1个FA或1个PA。每个FA或PA均可独立运行,完成1个特定功能。原子化服务的大小不能超过10MB。
原子化服务在桌面上是没有图标的,用户可以通过”服务中心”对原子化服务进行统一地查看、搜索和管理。从屏幕左下角或右下角向斜上方滑动,即可进入”服务中心”,如下图所示:
在”我的服务”板块,展示了常用的服务;在”发现”板块,提供了全量的服务供用户进行管理和使用。
原子化服务在”服务中心”的显示形式为卡片,可以将其添加到桌面。这就是卡片与原子化服务的关系。
打开DevEco Studio,创建一个HarmonyOS的工程,然后选择模板Empty Ability(JS)或Empty Ability(Java),点击按钮Next,进入到工程配置界面,如下图所示:
其中,工程类型有两种:一种是”Service”,也就是原子化服务;另一种是”Application”,也就是传统的应用。此外,还可以选择”是否在服务中心进行展示”。我们将工程类型指定为”原子化服务”,并且选择”在服务中心进行展示”。按照上图进行配置之后,点击按钮Finish以创建一个工程。
接下来,把鸿蒙手机连接到电脑上,对工程的主模块entry自动签名,如下图所示:
签名之后,将工程运行到鸿蒙手机上,显示出了主界面。由于该工程的类型是”原子化服务”,所以在桌面上并没有相应的图标。由于在创建工程时选择了”在服务中心进行展示”,因此,打开服务中心,就看到了相应的入口卡片,如下图所示:
长按卡片,可以进入相应的服务,如下图所示:
点击卡片,可以将其”添加到我的服务”或”添加到桌面”,如下图所示:
好,这样,就给大家讲清楚了卡片、原子化服务和服务中心的关系。
四、卡片的整体框架
华为官方给出了一张卡片的整体框架图,如下图所示:
可能很多朋友看到这张图就直接晕菜了。我们将其简化一下,如下图所示:
图的最左边是卡片提供方,要么是传统应用,要么是原子化服务。之所以将两者称之为卡片提供方,是因为传统应用或原子化服务中的Page Ability为卡片提供了表现素材,卡片是Page Ability的表现形式。在传统应用或原子化服务中定义了卡片的生命周期回调方法。图的最右边是卡片使用方,要么是桌面,要么是服务中心。之所以将两者称之为卡片使用方,是因为用户通过桌面或服务中心来使用卡片。图的中间是卡片管理服务,他是卡片的大管家,是卡片提供方和卡片使用方的中介和桥梁。
以卡片的定时或定点刷新为例,如果一个卡片在config.json中配置了定时或定点刷新,具体的流程如下图所示:
❶ timer事件会通知卡片管理服务;
❷ 卡片管理服务会去卡片提供方的对象管理模块中找到对应的卡片提供方;
❸ 卡片提供方回调卡片的生命周期刷新方法;
❹ 卡片提供方将刷新数据返回给卡片管理服务;
❺ 卡片管理服务根据卡片名称查找卡片使用方;
❻ 卡片管理服务刷新卡片使用方的卡片。
好,了解了卡片的整体框架之后,接下来,我们就正式进入到实操部分。我会首先给大家介绍如何使用JS开发卡片,然后再来介绍如何使用Java开发卡片。
五、 使用JS开发卡片
5.1 使用模板创建卡片
打开DevEco Studio,创建一个HarmonyOS的工程,然后选择模板Empty Ability(JS),点击按钮Next,进入到工程配置界面,如下图所示:
其中,将工程类型指定为传统应用,并且不选择”在服务中心进行展示”。按照上图进行配置之后,点击按钮Finish以创建一个工程。
如何在一个传统应用的工程中创建卡片呢?在目录entry上点击右键,在弹出的菜单中选择New,然后在弹出的子菜单中点击Service Widget,如下图所示:
这里的Service Widget指的就是卡片。
在模板选择界面,选择基本的模板Grid Pattern,点击按钮Next,进入到卡片配置界面,如下图所示:
首先配置卡片的名称和描述;然后配置卡片关联的Page Ability;然后配置卡片的编程语言类型是JS;接下来配置卡片的JS组件名称;最后配置卡片支持的规格,其中,2*2的小尺寸是必须要支持的,我们再勾选一个1*2的微尺寸。点击按钮Finish以创建一个卡片。
重复刚才的操作,在工程中再创建一个卡片,卡片配置界面如下图所示:
其中,卡片支持的规格,除了默认的2*2小尺寸之外,再勾选一下2*4的中尺寸和4*4的大尺寸。
这样,DevEco Studio就自动帮我们生成了一些目录和文件。先打开js子目录,如下图所示:
其中,main_widget1和
main_widget2是创建卡片时配置的JS组件名称;index.hml中定义了卡片中包含哪些UI组件;index.css中定义了卡片中的UI组件都长什么样;index.json中定义了卡片中动态绑定的数据,此外,还可以定义click触发的事件,稍候会给大家详细介绍。
再打开java子目录,如下图所示:
其中,MainWidget1和MainWidget2是创建卡片时配置的卡片名称;在MainAbility中添加了卡片的生命周期回调方法,如:onCreateForm()、onUpdateForm()、onTriggerFormEvent()、等。此外,还添加了FormControllerManager、FormController、以及两个以Impl结尾的实现类,这4个文件到底有什么用呢?稍候给大家介绍。
最后,打开config.json看一下,里面自动添加了很多配置,如下图所示:
MainAbility添加了标签”forms”,这里的form就是卡片的意思,和Service Widget是一回事儿。”forms”是一个数组,包含两个元素,分别表示我们创建的两个卡片。顺便提一下,在前面我们有讲到:“可以在config.json中为每个Page Ability配置0~16个卡片”,也就是说,数组”forms”中最多可以包含16个元素。上图的最下方还添加了一个标签”js”,”js”也是一个数组,包含三个元素,其中后两个元素就是两个卡片对应的js组件,”name”分别是”main_widget1”和”main_widget2”,这两个值就对应着上面的标签”forms”中”jsComponentName”的两个值。也就是说,上面的标签”forms”中卡片的js组件,是在下面的标签”js”中定义的。
我们再来看一下上面的标签”forms”中卡片的配置:
- “isDefault”表示该卡片是否为默认的上滑卡片,也就是用手指按下应用图标的同时往上滑时弹出的卡片。
- “scheduledUpdateTime”表示卡片定点刷新的时刻,采用24小时制,精确到分钟。
- “defaultDimension“表示卡片的默认尺寸规格,取值必须在下面的“supportDimensions“所配置的列表中。
- “colorMode“表示卡片的主题样式,默认值是”auto”,表示自适应,还可以取值为”dark”或”light”,分别表示深色主题和浅色主题。
- “supportDimensions“表示卡片支持的尺寸规格,也就是我们在创建卡片时配置的尺寸规格。
- “updateEnabled”表示卡片是否支持定时刷新或定点刷新,优先选择定时刷新。
- “updateDuration“表示卡片定时刷新的周期。当取值为0时,表示该参数不生效;当取值为正整数N时,表示刷新周期为30*N分钟。
接下来,我们先对工程的主模块entry自动签名,然后看一下运行效果。运行之后,在桌面上应用图标的下方显示了一条横线,表示该应用是支持卡片的,如下图所示:
在桌面上长按应用的图标,在弹出的菜单中点击”服务卡片”,就显示出了应用的所有卡片,如下图所示:
上下滑动所有卡片,总共有5个,其中,名为MainWidget1的卡片有两个,尺寸分别是1*2和2*2,名为MainWidget2的卡片有三个,尺寸分别是2*2、2*4和4*4。此外,名为MainWidget1的2*2的卡片被设为了上滑卡片,这是因为:在config.json中,将”isDefault”设为了”true”,并且将”defaultDimension”设为了” 2*2”,如下图所示:
5.2 卡片的初始化
当我们在桌面上长按应用的图标然后显示所有卡片的时候,MainAbility中卡片的生命周期方法onCreateForm()会被自动回调,方法onCreateForm()的实现如下图所示:
因为总共有5个卡片,所以方法onCreateForm()会被回调5次,如下图所示:
在方法onCreateForm()中,分别调用intent.getLongParam()、intent.getStringParam()和intent.getIntParam()获得了卡片的id、名称和尺寸。此外,在方法onCreateForm()中可以进行一些卡片的初始化操作。大家想想看,现在应用内只有5个卡片,假如应用内有几十个卡片,难道要把这几十个卡片的所有初始化操作都写在方法onCreateForm()中吗?显然是不好的做法!为此,通过模板自动生成的代码中,为我们提供了FormControllerManager、FormController、XxxImpl这几个类。其中,FormControllerManager是卡片管理器的大管家;FormController是卡片的管理器,他是一个抽象类;XxxImpl实现了FormController,MainWidget1Impl是名为MainWidget1的卡片对应的卡片控制器,MainWidget2Impl是名为MainWidget2的卡片对应的卡片控制器。这样,就可以根据卡片的名称对卡片进行独立控制了。在方法onCreateForm()中,首先得到FormControllerManager的实例,然后根据卡片ID得到对应的卡片控制器,也就是对应的XxxImpl的实例,最后调用对应的XxxImpl中的方法bindFormData()。
打开MainWidget1Impl,方法bindFormData()中的代码如下所示:
根据卡片的尺寸,分别设置了两个变量”mini”和”dim2X4”的值,这两个变量是子目录main_widget1中的index.json中的两个变量。这里顺便说一下,在index.hml中,很多变量都使用两个花括号括了起来,这些变量的值是在程序的运行过程中动态确定的,这种技术称之为动态绑定。这些变量的初始值在index.json中的标签”data”中进行了定义。所以,在方法bindFormData()中,根据卡片的尺寸修改了两个动态绑定的变量的值。我们对自动生成的代码再修改一下,如下图所示:
同时,对MainWidget2Impl中的方法bindFormData()也修改一下,如下图所示:
运行工程,显示所有卡片,如下图所示:
所有卡片的标题都被修改了(尺寸最小的卡片除外,因为他本来就不显示标题)。
5.3 卡片的定点/定时刷新
接下来,我们试一下卡片的定点刷新。打开config.json,先将MainWidget2对应的标签”updateDuration”修改为0,以关闭定时刷新。对于标签“scheduledUpdateTime”设定的时刻,当到达之后,MainAbility中卡片的回调方法onUpdateForm()就会被自动调用,如下图所示:
在方法体的最后,调用了卡片控制器的方法updateFormData()。打开MainWidget2Impl,在方法updateFormData()中,添加如下代码:
首先,将要刷新的数据存放在一个ZSONObject实例中,然后,将其封装在一个FormBindingData的实例bindingData中,最后,调用MainAbility的方法updateForm(),并将bindingData作为第二个实参。
打开config.json,将标签“scheduledUpdateTime”的值修改为当前时刻的两分钟之后。
运行工程,将ManWidget2对应的三个卡片都添加到桌面上,当到达设定的定点时刻之后,三个卡片的标题都刷新了,如下图所示:
5.4 卡片的跳转事件
对于桌面上名为MainWidget2的三个卡片,接下来我们要实现的功能是:点击任意一个卡片中左上方的图片,都跳转到SecondAbility对应的页面。
新建一个名为SecondAbility的Page Ability,其所在的包是com.zrc.demos。
打开子目录main_widget2中的index.json,添加如下配置:
其中,标签”actions”用于定义所有的事件,目前只定义了一个名为“startSecondAbility”的事件。将标签“action”的值设置为“router”,表示该事件是一个跳转事件。标签“abilityName”的值指定了跳转的目标Ability。标签“params”的值指定了跳转时携带的数据。
打开子目录main_widget2中的index.hml,在标签image中添加一个属性onclick,并将值设置为刚刚在index.json中定义的action的名称“startSecondAbility”,如下图所示:
打开SecondAbilitySlice,在回调方法onStart()中获取跳转时携带的数据,如下图所示:
首先,根据key的值“params”获得一个字符串格式的JSON数据;然后,调用ZSONObject.stringToZSON()将其转换为一个ZSONObject的实例data;最后,从data中分别获得”param1”和”param2”这两个key对应的value。
运行工程,将名为MainWidget2的任意一个卡片添加到桌面上,点击卡片中左上方的图片,跳转到了SecondAbility对应的页面,打印出的log如下所示:
5.5 卡片的消息事件
除了支持跳转事件,卡片还支持消息事件。当触发消息事件时,卡片所对应Page Ability的生命周期方法onTriggerFormEvent()会被自动回调。接下来我们要实现的功能是:对于名为MainWidget2的任意一个卡片,点击卡片的空白处,都会回调方法onTriggerFormEvent()。
打开子目录main_widget2中的index.json,添加如下配置:
在标签”actions”中再定义了一个名为“sendMessageEvent”的事件。将标签“action”的值设置为“message”,表示该事件是一个消息事件。标签“params”中定义了相关的数据。
打开子目录main_widget2中的index.hml,在第6行的标签div中添加一个属性onclick,并将值设置为刚刚在index.json中定义的action的名称“sendMessageEvent”,如下图所示:
通过模板创建卡片时自动生成的方法onTriggerFormEvent(),如下图所示:
在方法体的最后,调用了卡片控制器的方法onTriggerFormEvent()。因此,打开MainWidget2Impl,在方法onTriggerFormEvent()中,添加如下代码:
首先,message是一个字符串格式的JSON数据;然后,调用ZSONObject.stringToZSON()将message转换为一个ZSONObject的实例data;最后,从data中分别获得”p1”和”p2”这两个key对应的value。
运行工程,将名为MainWidget2的任意一个卡片添加到桌面上,点击卡片的空白处,卡片所对应Page Ability的生命周期方法onTriggerFormEvent()被自动回调,打印出的log如下所示:
六、使用Java开发卡片
请不要跳过前面的“使用JS开发卡片”,因为前后是有联系的,即便你对JS不熟悉,相信你也可以看懂的。此外,大家在学习接下来的内容时,请注意与前面的“使用JS开发卡片”进行对比。
6.1 使用模板创建卡片
首先,我们使用模板创建一个Java类型的卡片。在目录entry上点击右键,在弹出的菜单中选择New,然后在弹出的子菜单中点击Service Widget,在模板选择界面,选择基本的模板Grid Pattern,点击按钮Next,进入到卡片配置界面,如下图所示:
其中,配置卡片的编程语言类型为JAVA。为了与名为MainWidget2的三个卡片进行对比,我们也配置三个尺寸:2*2、2*4、4*4。
这样,DevEco Studio就自动帮我们生成了一些目录和文件。
打开java子目录,自动创建了一个子目录mainwidget3,并且自动创建了一个文件MainWidget3Impl以作为卡片MainWidget3的控制器,如下图所示:
打开js子目录,没有自动创建任何目录和文件,而是在子目录layout中自动创建了3个布局文件,如下图所示:
这是3个不同尺寸卡片的布局文件,通过文件名就能看出每个布局文件所对应的卡片尺寸。布局文件里定义了卡片中包含哪些UI组件以及UI组件都长什么样,因此,布局文件就相当于子目录js中的hml文件和css文件。
最后,打开config.json看一下,里面自动添加了一些配置,如下图所示:
与js不同的是,java卡片要分别配置”landscapeLayouts”和“portraitLayouts”,分别表示卡片对应的横向布局文件和竖向布局文件。值得注意的是:配置的数组中的元素要与“supportDemensions”中的元素一一对应。
运行工程,在桌面上长按应用的图标,在弹出的菜单中点击”服务卡片”,就显示出了应用的所有卡片,现在总共有8个,其中,名为MainWidget3的卡片有3个。
6.2 卡片的初始化
当我们在桌面上长按应用的图标然后显示所有卡片的时候,MainAbility中卡片的生命周期方法onCreateForm()会被自动回调,因为总共有8个卡片,所以方法onCreateForm()会被回调8次。在方法体的最后,调用了对应的XxxImpl中的方法bindFormData()。
打开MainWidget3Impl,方法bindFormData()中的代码如下所示:
三个卡片的布局文件都放在了一个map中,根据卡片的尺寸得到对应的布局文件,然后创建一个提供者卡片信息ProviderFormInfo的实例并将其返回了。
接下来,我们实现与MainWidget2相同的功能:修改三个卡片的标题。首先,在卡片对应的三个布局文件中,都为卡片标题对应的组件Text添加一个id,如下图所示:
然后,在MainWidget3Impl中添加一个静态常量,如下图所示:
在返回ProviderFormInfo的实例之前,先根据卡片的尺寸修改对应的标题,如下图所示:
首先,构造一个ComponentProvider的实例,用于表示一个Java卡片实例;然后,根据卡片的尺寸修改布局文件中标题的值,其中,标题是通过布局文件中组件Text对应的id进行指定的;最后,调用providerFormInfo的方法mergeActions()并且将componentProvider作为实参传过去。
运行工程,名为MainWidget3的三个卡片的标题都被修改了,如下图所示:
6.3 卡片的定点/定时更新
接下来,我们试一下卡片的定点刷新。打开config.json,先将MainWidget3对应的标签”updateDuration”修改为0,以关闭定时刷新。对于标签“scheduledUpdateTime”设定的时刻,当到达之后,MainAbility中卡片的回调方法onUpdateForm()就会被自动调用,在方法体的最后,调用了卡片控制器的方法updateFormData()。打开MainWidget3Impl,在方法updateFormData()中,添加如下代码:
首先,构造一个ComponentProvider的实例,用于表示一个Java卡片实例,传入的第一个实参是根据卡片尺寸得到的布局文件。然后,调用方法setText()修改卡片的标题;最后,调用MainAbility的方法updateForm(),并将componentProvider作为第二个实参。
打开config.json,将标签“scheduledUpdateTime”的值修改为当前时刻的两分钟之后。
运行工程,将ManWidget3对应的三个卡片都添加到桌面上,当到达设定的定点时刻之后,三个卡片的标题都刷新了。
6.4 卡片的事件
与JS不同的是,Java卡片是通过IntentAgent来设置事件的。以跳转事件为例,接下来我们实现的功能是:对于名为MainWidget3的、尺寸为2*4的卡片,当点击标题时跳转到SecondAbility对应的页面。
打开MainWidget3Impl,在方法bindFormData()中,当卡片尺寸为2*4时,调用componentProvider的方法setIntentAgent(),传入的第一个实参是标题在布局文件中的id,第二个实参是用于页面跳转的IntentAgent实例。代码如下所示:
接下来,定义方法getStartAbilityIntentAgent()的具体实现,代码如下所示:
运行工程,将名为MainWidget3的、尺寸为2*4的卡片添加到桌面,点击卡片中的标题,跳转到了SecondAbility对应的页面。
七、开发卡片到底该使用JS还是使用Java
官方文档中给出了JS卡片和Java卡片的场景能力差异,如下表所示:
通过该表可以看出:
- JS卡片比JAVA卡片支持的控件和能力都更丰富。
- Java卡片适合作为一个直达入口,没有复杂的页面和事件。
- JS卡片适合有复杂界面的卡片。
我个人更推荐使用JS卡片,因为使用起来更灵活、更简单、功能更强大!
好,关于鸿蒙的卡片开发,就给大家分享这么多内容,喜欢这篇文章的朋友麻烦给个三连^_^
后续,我会撰写更多的鸿蒙技术文章分享给大家,欢迎关注我的鸿蒙专栏:https://harmonyos.51cto.com/column/27。
文章相关附件可以点击下面的原文链接前往下载,原文链接:https://harmonyos.51cto.com/posts/6153
|