上个月华为发布了鸿蒙,我的使用感受就是两个字,舒服。特别是服务卡片,便捷的信息展示,服务高效直达。


我平时经常在 PC 端逛 51cto 的鸿蒙社区,好多知识都是在社区学习的。
但是有时候不方便开电脑,用手机就需要打开微信,点公众号搜索技术社区,然后点击逛社区,有点麻烦。
如果此时有鸿蒙的服务卡片话,操作就简单了,只需要解锁,点击卡片,巴适啊。

每次用手机进社区刚巧能节省大约 10s,不要小看这几秒钟,以鸿蒙的体量,每人每天这么几次节省的时间就是天文数字。
接下来就具体分析下服务卡片究竟可以干哪些事情?
文章推荐服务卡片

①界面设计
服务卡片有 4 种尺寸,分别是 1×2 微卡片、2×2 小卡片、2×4 中卡片、4×4 大卡片。

从上图可以看出,服务卡片可以实现在不同终端设备上的展示和自适应,但其实设计这种多终端多尺寸的服务卡片,代码却并不复杂。
<div class="div_title" onclick="sendRouteEvent"><!--第一行标题-->
<span class="div_title_top" if="{{$item.is_top}}">置顶</span>
<span class="div_title_good" if="{{$item.is_good}}">精</span>
<span class="div_title_title">{{$item.title}}</span>
</div>
<div class="div_tags"><!--第二行标签-->
<text class="div_tags_text" style="display-index: 5;" if="{{$item.tags[0]}}">{{$item.tags[1]}}</text>
<text class="div_tags_text" style="display-index: 4;" if="{{$item.tags[2]}}">{{$item.tags[3]}}</text>
<text class="div_tags_text" style="display-index: 3;" if="{{$item.tags[4]}}">{{$item.tags[5]}}</text>
<text class="div_tags_text" style="display-index: 2;" if="{{$item.tags[6]}}">{{$item.tags[7]}}</text>
<text class="div_tags_text" style="display-index: 1;" if="{{$item.tags[8]}}">{{$item.tags[9]}}</text>
</div>
<div class="div_user"><!--第三行作者-->
<image class="div_user_image" src="common/image_1.png" style="display-index: 3;"></image>
<text class="div_user_username" style="display-index: 3;">{{$item.username}}</text>
<text class="div_user_username" style="display-index: 2;">{{$item.reply_time}}</text>
<text class="div_user_username" style="display-index: 1;">最后一次回复:</text>
<text class="div_user_username" style="display-index: 1;">{{$item.reply_username}}</text>
</div>
<divider class="divider"></divider><!--分割线-->
可以使用少量的代码,实现在手机和平板 2 个终端 6 个尺寸的服务卡片,使用鸿蒙的原子布局能力。点击查看原子布局官方文档:
https://developer.harmonyos.com/cn/docs/documentation/doc-references/js-components-common-atomic-layout-0000001062070665
这里说下我遇到的坑,第一行标题中的置顶和精,使用的是 <span> 组件,但是 <span> 的样式目前还不支持背景颜色设置,所以要想实现图中展示的效果,还得将代码稍微改动下,用 <stack> 曲线救国。
<div class="div_title" onclick="sendRouteEvent"><!--第一行标题-->
<stack>
<text>
<span class="div_title_top" if="{{$item.is_top}}">置顶置顶</span>
<span class="div_title_good" if="{{$item.is_good}}">精精</span>
<span class="div_title_title">{{$item.title}}</span>
</text>
<div><!--利用stack堆叠一层text,在text上设置背景色-->
<text class="div_title_top" if="{{$item.is_top}}">置顶</text>
<text class="div_title_good" if="{{$item.is_good}}">精</text>
</div>
</stack>
</div>
.div_title_top {
text-align:center;
width: 32px;
height: 16px;
font-size: 12px;
font-weight: 400;
margin: 3px;
border-radius: 3px;
color: #FFFFFF;
background-color: #f40d04;
}
.div_title_good {
text-align:center;
width: 22px;
height: 16px;
font-size: 12px;
font-weight: 400;
margin: 3px;
border-radius: 3px;
color: #FFFFFF;
background-color: #F7748F;
}
这样就可以完整显示一条文章内容信息了,接下只需要放入 list 列表组件,就可以实现整个推荐文章的列表页面了。
<list class="list_root" for="list">
<list-item class="list_item">
... ...
</list-item>
</list>

②卡片更新
config.json 文件 “abilities” 的 forms 模块配置细节如下:
"forms": [
{
"jsComponentName": "widget",
"isDefault": true,
"scheduledUpdateTime": "10:30",//定点刷新的时刻,采用24小时制,精确到分钟。"updateDuration": 0时,才会生效。
"defaultDimension": "4*4",
"name": "widget",
"description": "This is a service widget",
"colorMode": "auto",
"type": "JS",
"supportDimensions": [
"2*2",
"2*4",
"4*4"
],
"updateEnabled": true, //表示卡片是否支持周期性刷新
"updateDuration": 1 //卡片定时刷新的更新周期,1为30分钟,2为60分钟,N为30*N分钟
},
... ...
]
可以在配置文件中设置定时或者定点更新卡片,当更新触发时会调用 MainAbility 下的 onUpdateForm(long formId) 方法:
public class MainAbility extends Ability {
... ...
protected ProviderFormInfo onCreateForm(Intent intent) {...}//在服务卡片上右击>>服务卡片(或上滑)时,通知接口
protected void onUpdateForm(long formId) {...}//在服务卡片请求更新,定时更新时,通知接口
protected void onDeleteForm(long formId) {..}//在服务卡片被删除时,通知接口
protected void onTriggerFormEvent(long formId, String message) {...}//JS服务卡片click时,通知接口
}
③POST 请求
而上面的方法最终调用了卡片控制器 WidgetImpl 的方法 updateFormData()。所以最终需要卡片控制器的 updateFormData() 中,添加如下更新代码:
@Override
public void updateFormData(long formId, Object... vars) {
HiLog.info(TAG, "update form data timing, default 30 minutes");
//获取文章索引
String url = "https://api-harmonyos.51cto.com/";
Map<String,String> map = new HashMap<>();
map.put("method", "articles.index");
map.put("page", "1");
map.put("page_size", "50");
map.put("sort", "time");
map.put("is_file", "0");
map.put("search_type", "recommend");
map.put("platform_type", "1");
map.put("sign", getSign());
map.put("timestamp", timestamp());
map.put("token", getToken());
ZZRHttp.post(url, map, new ZZRCallBack.CallBackString() {
@Override
public void onFailure(int i, String s) {HiLog.info(TAG,"post请求失败");}
@Override
public void onResponse(String s) {
HiLog.info(TAG,"post请求成功"+s);
try{
//解析返回的json字符串
ArticlesIndex articlesIndex = JSON.parseObject(s,ArticlesIndex.class);
ArticlesIndex.Data data = articlesIndex.getData();
//获取解析结果中的list列表
List<ArticlesIndex.Data.list> lists = data.getList();
ArticlesIndex.Data.list list = lists.get(0);
HiLog.info(TAG,"解析成功");
//这部分用来更新卡片信息
ZSONObject zsonObject = new ZSONObject(); //1.将要刷新的数据存放在一个ZSONObject实例中
zsonObject.put("list",lists); //2.更新数据,对于list控件,可以直接赋值list
FormBindingData formBindingData = new FormBindingData(zsonObject); //3.将其封装在一个FormBindingData的实例中
try {
((MainAbility)context).updateForm(formId,formBindingData); //4.调用MainAbility的方法updateForm(),并将formBindingData作为第二个实参
} catch (FormException e) {
e.printStackTrace();
HiLog.info(TAG, "更新卡片失败");
}
}catch (Exception e){
HiLog.info(TAG, "解析失败");
}
}
});
}
④添加权限和依赖包
{
... ...
"module": {
... ...
"reqPermissions": [{"name":"ohos.permission.INTERNET"}]
}
}
添加依赖包:找到 entry/build.gradle 文件,在 dependencies 下添加。
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.har'])
testImplementation 'junit:junit:4.13'
ohosTestImplementation 'com.huawei.ohos.testkit:runner:1.0.0.100'
// ZZRHttp 可以单独一个进程进行http请求
implementation 'com.zzrv5.zzrhttp:ZZRHttp:1.0.1'
// fastjson 可以解析JSON格式
implementation group: 'com.alibaba', name: 'fastjson', version: '1.2.75'
}
POST 请求最终会得到一段 JSON 格式的字符串,内容如下图:

⑤解析 JSON
如果不想自己写,也可以百度搜
”JSON 生成 Java 实体类“,可直接生成。
package com.liangzili.demos.api;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class ArticlesIndex {
public static class Data{
private String list_type;
public String getList_type() {
return list_type;
}
public void setList_type(String list_type) {
this.list_type = list_type;
}
public static class list{
public static class Answers_users{
private String nick_name;
public String getNick_name() {return nick_name;}
public void setNick_name(String nick_name) {this.nick_name = nick_name;}
}
private List<Answers_users> answers_users;
public List<Answers_users> getAnswers_users() {return answers_users;}
public void setAnswers_users(List<Answers_users> answers_users) {this.answers_users = answers_users;}
private List<String> tags;
public List<String> getTags() {return tags;}
public void setTags(List<String> tags) {
List<String> strList = new ArrayList<>();
for (String str : tags) {
strList.add("true");
strList.add(str);
}
this.tags = strList;
}
private String title;
public String getTitle() {return title;}
public void setTitle(String title) {this.title = title;}
private Boolean is_good;
public Boolean getIs_good() {return is_good;}
public void setIs_good(Boolean is_good) {this.is_good = is_good;}
private Boolean is_top;
public Boolean getIs_top() {return is_top;}
public void setIs_top(Boolean is_top) {this.is_top = is_top;}
};
private List<list> list;
public List<Data.list> getList() {return list;}
public void setList(List<Data.list> list) {this.list = list;}
};
private Data data;
public Data getData() {return data;}
public void setData(Data data) {this.data = data;}
}
在更新数据前,需要设置卡片的 index.json 内容如下,这个文件的内容和上面我们进行 post 请求数据时的返回内容格式一致,这样就可以在更新卡片内容时,直接更新 list 的内容。
{
"data": {
"list": [
{
"articles_id":"",
"articles_type":0,
"title":"",
"tags":[
""
],
"create_time":"",
"create_time_all":"",
"create_time_wap":"",
"avatar":"",
"user_id":0,
"username":"",
"is_reply":0,
"views":0,
"is_good":true,
"is_file":0,
"is_question":0,
"is_video":0,
"image":"",
"downloads":0,
"play_num":0,
"supports":0,
"comments":0,
"duration":0,
"is_top":true,
"reply_user_id":0,
"reply_avatar":"",
"reply_username":"",
"reply_time":""
}
]
}
}
问答服务卡片
接下来是问答模块的服务卡片,效果如图:

这个服务卡片和前面的文章卡片类似,区别就在于 POST 的请求方法,和 JSON 的返回值格式不太一样,掌握了方法,稍微修改一下即可,贴一下 POST 的内容吧:
//获取问答模块
String url = "https://api-harmonyos.51cto.com/";
Map<String,String> map = new HashMap<>();
map.put("method", "ask.qList");
map.put("tag_type", "3");
map.put("page", "1");
map.put("page_size", "30");
map.put("q", "");
map.put("platform_type", "1");
map.put("sign", getSign());
map.put("timestamp", timestamp());
map.put("token", getToken());
服务卡片除了信息展示,还有一个重要的功能,通过轻量交互行为实现服务直达、减少层级跳转的。前面的文章推荐卡片没有说跳转,是因为我在 list 列表的跳转事件上遇到一个坑。
https://developer.harmonyos.com/cn/docs/documentation/doc-references/js-service-widget-syntax-hml-0000001152828575
①消息事件(message)
在 index.hml 中给要触发的控件上添加 onclick,比如:onclick=“sendMessageEvent”。
在 index.json 中,添加对应的 actions。
{
"data": {
},
"actions": {
"sendMessageEvent": {
"action": "message",
"params": {
"p1": "v1",
"p2": "v2"
}
}
}
}
@Override
protected void onTriggerFormEvent(long formId, String message) {
HiLog.info(TAG, "onTriggerFormEvent: " + message); //params的内容就通过message传递过来
super.onTriggerFormEvent(formId, message);
FormControllerManager formControllerManager = FormControllerManager.getInstance(this);
FormController formController = formControllerManager.getController(formId);//通过formId得到卡片控制器
formController.onTriggerFormEvent(formId, message);//接着再调用,控制器 Widget1Impl
}
public void onTriggerFormEvent(long formId, String message) {
HiLog.info(TAG, "onTriggerFormEvent."+message);
ZSONObject data = ZSONObject.stringToZSON(message);
String p1 = data.getString("p1");
String p2 = data.getString("p2");
HiLog.info(TAG,"p1:"+p1+",p2:"+p2);
}
②跳转事件(router)
在 index.hml 中给要触发的控件上添加 onclick,比如:onclick=“sendRouteEvent”。
在 index.json 中,添加对应的 actions,跳转事件要多加一个参数"abilityName",指定要跳转的页面:
{
"data": {
},
"actions": {
"sendRouteEvent": {
"action": "router",
"abilityName": "com.liangzili.servicewidget.RoutePageAbility",
"params": {
"p1": "v1",
"p2": "v2"
}
}
}
}

"abilities": [
... ...
{
"orientation": "unspecified",
"name": "com.liangzili.demos.slice.MainAbilityWeb",
"icon": "$media:icon",
"description": "$string:mainabilityweb_description",
"label": "$string:entry_MainAbilityWeb",
"type": "page",
"launchType": "standard"
}
public class RoutePageAbilitySlice extends AbilitySlice {
private static final HiLogLabel TAG = new HiLogLabel(HiLog.LOG_APP,0x01818,"卡片跳转");
@Override
public void onStart(Intent intent) {
super.onStart(intent);
super.setUIContent(ResourceTable.Layout_ability_bilibili_page);
//添加参数验证
String param = intent.getStringParam("params");//从intent中获取 跳转事件定义的params字段的值
if(param !=null){
HiLog.info(TAG,"param:"+param);
ZSONObject data = ZSONObject.stringToZSON(param);
String p1 = data.getString("p1");
String p2 = data.getString("p2");
HiLog.info(TAG,"p1:"+p1+",p2:"+p2);
}
}
}
③list 跳转事件
<list class="list_root" for="list">
<list-item class="list_item">
<div class="div_title" onclick="sendRouteEvent"><!--第一行标题-->
<text class="div_title_title">{{$item.title}}</text>
</div>
<div class="div_tags"><!--第二行标签-->
<text class="div_tags_text" style="display-index: 5;" if="{{$item.tags[0]}}">{{$item.tags[1]}}</text>
<text class="div_tags_text" style="display-index: 4;" if="{{$item.tags[2]}}">{{$item.tags[3]}}</text>
<text class="div_tags_text" style="display-index: 3;" if="{{$item.tags[4]}}">{{$item.tags[5]}}</text>
<text class="div_tags_text" style="display-index: 2;" if="{{$item.tags[6]}}">{{$item.tags[7]}}</text>
<text class="div_tags_text" style="display-index: 1;" if="{{$item.tags[8]}}">{{$item.tags[9]}}</text>
</div>
<div class="div_user"><!--第三行作者-->
<text class="div_user_username" style="display-index: 2;">{{$item.answers_user[0].nick_name}}</text>
<text class="div_user_username" style="display-index: 1;">{{$item.created_at}}</text>
</div>
<divider class="divider"></divider>
</list-item>
</list>
"actions": {
"sendRouteEvent": {
"action": "router",
"abilityName": "com.liangzili.demos.MainAbility",
"params": {
"index": "{{$idx}}",
"url": "{{$item.url}}"
}
}
荣誉认证卡片

①webview
<ohos.agp.components.webengine.WebView
ohos:id="$+id:webview"
ohos:height="match_parent"
ohos:width="match_parent">
</ohos.agp.components.webengine.WebView>
@Override
public void onStart(Intent intent) {
super.onStart(intent);
super.setUIContent(ResourceTable.Layout_ability_main);
//启动webview
WebView webView = (WebView) findComponentById(ResourceTable.Id_webview);
webView.getWebConfig().setJavaScriptPermit(true); // 如果网页需要使用JavaScript,增加此行;如何使用JavaScript下文有详细介绍
// 坑:51cto的主页首次打开会有个弹窗,关闭弹窗会在Local Storage中设置"coupon=1",不开启这个将无法关闭弹窗。
webView.getWebConfig().setWebStoragePermit(true); // 设置是否启用HTML5 DOM存储。
String url ="https://harmonyos-m.51cto.com";
webView.load(url);
}
②取消标题栏


"metaData":{
"customizeData":[
{
"name": "hwc-theme",
"value": "androidhwext:style/Theme.Emui.Light.NoTitleBar",
"extra": ""
}
]
},
③保存 cookie
https://developer.harmonyos.com/cn/docs/documentation/doc-references/cookiestore-0000001091611340

这里我又双叕遇到一个坑,这个 persist() 方法我尝试了很多次,只要清理后台,cookie 就会丢失,难道是我对这个方法有什么误解,打开的方式不对。
到现在也没有成功,有知道如何使用的大佬,麻烦告知一声,这里先行谢过了。
④使用偏好型数据库
public void saveCookie(String url,String filename){
//先取出要保存的cookie
CookieStore cookieStore = CookieStore.getInstance();
String cookieStr = cookieStore.getCookie(url);
HiLog.info(TAG,"saveCookie(String url)"+url+cookieStr);
//然后将cooke转成map
Map<String,String> cookieMap = cookieToMap(cookieStr);
//最后将map写入数据库
MaptoDB(cookieMap,filename);
}
// cookieToMap
public static Map<String,String> cookieToMap(String value) {
Map<String, String> map = new HashMap<String, String>();
value = value.replace(" ", "");
if (value.contains(";")) {
String values[] = value.split(";");
for (String val : values) {
String vals[] = val.split("=");
map.put(vals[0], vals[1]);
}
} else {
String values[] = value.split("=");
map.put(values[0], values[1]);
}
return map;
}
// 将map写入数据库
public void MaptoDB(Map<String,String> map,String filename){
// 开启数据库
context = getContext();
DatabaseHelper databaseHelper = new DatabaseHelper(context);//1.创建数据库使用数据库操作的辅助类
Preferences preferences = databaseHelper.getPreferences(filename);//2.获取到对应文件名的Preferences实例
// 遍历map
for (Map.Entry<String, String> entry : map.entrySet()) {
System.out.println(entry.getKey() + "=" + entry.getValue());
preferences.putString(entry.getKey(),entry.getValue());//3.将数据写入Preferences实例,
}
preferences.flushSync();//4.通过flush()或者flushSync()将Preferences实例持久化。
}
public void readCookie(String url,String filename){
Map<String, ?> map = new HashMap<>();
//先从数据库中取出cookie
map = DBtoMap(filename);
//然后写入到cookieStore
CookieStore cookieStore = CookieStore.getInstance();//1.获取一个CookieStore的示例
for (Map.Entry<String, ?> entry : map.entrySet()) {
System.out.println(entry.getKey()+"="+entry.getValue().toString());
cookieStore.setCookie(url,entry.getKey()+"="+entry.getValue().toString());//2.写入数据,只能一条一条写
}
}
@Override
public void onStart(Intent intent) {
super.onStart(intent);
super.setUIContent(ResourceTable.Layout_ability_main);
readCookie("https://harmonyos-m.51cto.com","harmonyos-m");
readCookie("https://home.51cto.com","home");
readCookie("https://ucenter.51cto.com","ucenter");
}
@Override
protected void onStop() {}
@Override
protected void onBackground() {
saveCookie("https://harmonyos-m.51cto.com","harmonyos-m");
saveCookie("https://home.51cto.com","home");
saveCookie("https://ucenter.51cto.com","ucenter");
}
不过这样操作会触发一个新的问题,这里就不深究了,已经偏离服务卡片的初衷了。
以上就是我制作 51 社区服务卡片的过程了,听说 51CTO 官方版的服务卡片也马上上线了,期待啊!!
👇点击关注鸿蒙技术社区👇
了解鸿蒙一手资讯

点“阅读原文”了解更多




