51CTO鸿蒙社区服务卡片应用设计 原创 精华
@toc
前言
上个月华为发布了鸿蒙,我的使用感受就是两个字,舒服。特别是服务卡片,便捷的信息展示,服务高效直达。这么形容不够直观,用个项目给大家展示下。
假如51鸿蒙社区出了鸿蒙版,本项目中包含的内容和图片仅供学习和技术交流。
我平时经常在PC端逛51cto的鸿蒙社区,好多知识都是在社区学习的。但是有时候不方便开电脑,用手机就需要打开微信,点公众号搜索技术社区,然后点击逛社区,有点麻烦。如果此时有鸿蒙的服务卡片话,操作就简单了,只需要解锁,点击卡片,巴适啊。每次用手机进社区刚巧能节省大约10s,不要小看这几秒钟,以鸿蒙的体量,每人每天这么几次节省的时间就是天文数字。
接下来就具体分析下服务卡片究竟可以干哪些事情?
一、文章推荐服务卡片
社区访问最多的就是首页的“推荐内容”,而服务卡片正是为了提供用户容易使用且一目了然的信息内容,既然如此那就将“推荐内容”制作成服务卡片。
1.界面设计
服务卡片有4种尺寸,分别是1×2微卡片、2×2小卡片、2×4中卡片、4×4大卡片。显然1×2微卡片无法满足制作推荐内容卡片的需求。所以选择其他三种尺寸的服务卡片。
从上图可以看出,服务卡片可以实现在不同终端设备上的展示和自适应,但其实设计这种多终端多尺寸的服务卡片,代码却并不复杂。下面是我使用js开发的页面内容相关的代码。
<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个尺寸的服务卡片,使用鸿蒙的原子布局能力。点击查看原子布局官方文档
主要就是通过样式display-index值从小到大的顺序进行隐藏。
这里说下我遇到的坑,第一行标题中的置顶和精,使用的是<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>
2.卡片更新
接下来使用服务卡片自带的卡片管理服务,实现卡片周期性刷新等。参考官方文档
只需要在config.json中开启服务卡片的周期性更新,在onUpdateForm(long formId)方法下执行数据获取更新。
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时,通知接口
}
3.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, "解析失败");
}
}
});
}
在上述的代码中,使用了两个包需要导入,同时需要开启程序的联网权限
4.添加权限和依赖包
要在config.json配置文件的module中添加:“reqPermissions”: [{“name”:“ohos.permission.INTERNET”}],
{
... ...
"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格式的字符串,内容如下图,
5.解析JSON
但是返回是JSON格式需要进行解析,用的就是前面导入的依赖包fastjson,选择fastjson而不是jackson,是为了java类中只写要解析的数据,其他不需要的可以不写,参考下面的代码。如果不想自己写,也可以百度搜 ”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列表的跳转事件上遇到一个坑。
卡片支持click通用事件,事件类型:跳转事件(router)和消息事件(message)。详细说明参考官方文档
消息事件(message)
-
在index.hml中给要触发的控件上添加onclick,比如:onclick=“sendMessageEvent”
-
在index.json中,添加对应的actions
{ "data": { }, "actions": { "sendMessageEvent": { "action": "message", "params": { "p1": "v1", "p2": "v2" } } } }
-
如果是消息事件(message)当点击带有onclick的控件时,会触发MainAbility下的这个函数
@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 }
-
最后调用卡片控制器 Widget1Impl 中的onTriggerFormEvent()
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" } } } }
-
如下图所示添加一个Page Ability,比如:RoutePageAbility
IDE会自动在config.json中增加这个页面,没有这个配置信息是无法调用的。
"abilities": [
... ...
{
"orientation": "unspecified",
"name": "com.liangzili.demos.slice.MainAbilityWeb",
"icon": "$media:icon",
"description": "$string:mainabilityweb_description",
"label": "$string:entry_MainAbilityWeb",
"type": "page",
"launchType": "standard"
}
-
新建完成之后会增加RoutePageAbility 和 slice/RoutePageAbilitySlice 两个文件,可以在下面的代码中添加参数验证
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组件只能添加一个onclick,所以就有个问题,在点击的同时还需要获取点击的是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>
这个坑折磨了我好久,最终我发现在index.json中,可以使用$item,$idx获取到hml页面list的元素变量和索引。但是在官方文档并没有找到相关的内容,尝试了很久才解决这个问题。
"actions": {
"sendRouteEvent": {
"action": "router",
"abilityName": "com.liangzili.demos.MainAbility",
"params": {
"index": "{{$idx}}",
"url": "{{$item.url}}"
}
}
三、荣誉认证卡片
这两个服务卡片的和之前的卡片略有不同,主要是因为这两个服务卡片的信息需要登录账号才能够获取到.
1.webview
在鸿蒙中webview提供在应用中集成Web页面的能力。首先在打开APP的时候显示HarmonyOS技术社区的首页,在base/layout/ability_main.xml中添加
<ohos.agp.components.webengine.WebView
ohos:id="$+id:webview"
ohos:height="match_parent"
ohos:width="match_parent">
</ohos.agp.components.webengine.WebView>
接着在com/liangzili/demos/slice/MainAbilitySlice.java的启动函数中添加如下代码
@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);
}
2.取消标题栏
但此时还有个小问题就是这个标题栏,强迫症的我着实觉的太难受,需要添加一个配置取消这个标题栏,配置是网上查的,啥意思我不太清楚,能用就好
在abilities下要隐藏标题栏的页面下添加下面配置,添加在哪个页面隐藏哪个。
"metaData":{
"customizeData":[
{
"name": "hwc-theme",
"value": "androidhwext:style/Theme.Emui.Light.NoTitleBar",
"extra": ""
}
]
},
3.保存cookie
主要用到的就是CookieStore,在文档中CookieStore有一个persist()方法,看描述应该就是保存cookie信息的意思,参考官方文档
Modifier and Type | Method | Description |
---|---|---|
abstract void | persist() | Saves cookies to the device’s persistent storage. |
这里我又双叕遇到一个坑,这个persist()方法我尝试了很多次,只要清理后台,cookie就会丢失,难道是我对这个方法有什么误解,打开的方式不对。到现在也没有成功,有知道如何使用的大佬,麻烦告知一声,这里先行谢过了。
4.使用偏好型数据库
没办法只能取出cookie的内容,然后一条条保存到数据库了。首先制造一个保存指定域名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实例持久化。
}
接着制造从数据库中读取Cookie的函数
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.写入数据,只能一条一条写
}
}
最后在启动时调用readCookie,结束时调用saveCookie就可以了。
@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");
}
不过这样操作会触发一个新的问题,这里就不深究了,已经偏离服务卡片的初衷了。这里要感谢下社区@Whyalone,感谢指点迷津。听说51CTO官方版的服务卡片也马上上线了,期待啊!!
以上就是我制作51社区服务卡片的过程了,如果对你们有所帮助别忘了点赞支持啊,如果有问题也欢迎留言进行交流。
给大佬跪了!希望社区卡片早日上线
优秀~\(≧▽≦)/~~\(≧▽≦)/~
赞,来的正及时
路人甲前来打个酱油
太赞了!用微信打开逛社区确实耗时间,如果有小卡片之间就减少了体验层级
服务卡片用在社区这里真的秒,期待app快出来。到时候社区说不定就因此更加活跃了
小哥厉害!!学习了!
期待社区能做出楼主这样的效果。
确实,我也期待官方版快点来啊
看起来不错哦
那个cookieStore 中的persist(),我想了好久都没解决。后来还是用自带的轻量级数据库preferences解决,直接getcookie保存到preferences。每次加载webview.load前先setcookie解决。
能不能把代码放在Gitee?