前言

上个月华为发布了鸿蒙,我的使用感受就是两个字,舒服。特别是服务卡片,便捷的信息展示,服务高效直达。这么形容不够直观,用个项目给大家展示下。

假如51鸿蒙社区出了鸿蒙版,本项目中包含的内容和图片仅供学习和技术交流。

::: hljs-center

51ctoHMOS.png

:::

我平时经常在PC端逛51cto的鸿蒙社区,好多知识都是在社区学习的。但是有时候不方便开电脑,用手机就需要打开微信,点公众号搜索技术社区,然后点击逛社区,有点麻烦。如果此时有鸿蒙的服务卡片话,操作就简单了,只需要解锁,点击卡片,巴适啊。每次用手机进社区刚巧能节省大约10s,不要小看这几秒钟,以鸿蒙的体量,每人每天这么几次节省的时间就是天文数字。

::: hljs-center

微信鸿蒙对比.gif

:::

接下来就具体分析下服务卡片究竟可以干哪些事情?

一、文章推荐服务卡片

社区访问最多的就是首页的“推荐内容”,而服务卡片正是为了提供用户容易使用且一目了然的信息内容,既然如此那就将“推荐内容”制作成服务卡片。

::: hljs-center

image20210713175455156.png

:::

1.界面设计

服务卡片有4种尺寸,分别是1×2微卡片、2×2小卡片、2×4中卡片、4×4大卡片。显然1×2微卡片无法满足制作推荐内容卡片的需求。所以选择其他三种尺寸的服务卡片。

51文章推荐.png

从上图可以看出,服务卡片可以实现在不同终端设备上的展示和自适应,但其实设计这种多终端多尺寸的服务卡片,代码却并不复杂。下面是我使用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>

::: hljs-center

51推荐文章展示.gif

:::

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格式的字符串,内容如下图,
::: hljs-center

image20210714105055362.png

:::

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":""
      }
    ]
  }
}

二、问答服务卡片

接下来是问答模块的服务卡片,效果如图

::: hljs-center

51问答模块.png

:::

这个服务卡片和前面的文章卡片类似,区别就在于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)

  1. 在index.hml中给要触发的控件上添加onclick,比如:onclick="sendMessageEvent"

  2. 在index.json中,添加对应的actions

    {
     "data": {
     },
     "actions": {
       "sendMessageEvent": {
         "action": "message",
         "params": {
           "p1": "v1",
           "p2": "v2"
         }
       }
     }
    }
  3. 如果是消息事件(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
    }
  4. 最后调用卡片控制器 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)

  1. 在index.hml中给要触发的控件上添加onclick,比如:onclick="sendRouteEvent"

  2. 在index.json中,添加对应的actions,跳转事件要多加一个参数"abilityName",指定要跳转的页面

    {
     "data": {
     },
     "actions": {
       "sendRouteEvent": {
       "action": "router",
       "abilityName": "com.liangzili.servicewidget.RoutePageAbility",
         "params": {
           "p1": "v1",
           "p2": "v2"
         }
       }
     }
    }
  3. 如下图所示添加一个Page Ability,比如:RoutePageAbility

image20210701213924644.png

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"
      }
  1. 新建完成之后会增加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}}"
        }
    }

三、荣誉认证卡片

这两个服务卡片的和之前的卡片略有不同,主要是因为这两个服务卡片的信息需要登录账号才能够获取到.

51荣誉认证.png

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.取消标题栏

但此时还有个小问题就是这个标题栏,强迫症的我着实觉的太难受,需要添加一个配置取消这个标题栏,配置是网上查的,啥意思我不太清楚,能用就好

51去掉标题栏.png

image20210702200140417.png

在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社区服务卡片的过程了,如果对你们有所帮助别忘了点赞支持啊,如果有问题也欢迎留言进行交流。

想了解更多关于鸿蒙的内容,请访问:

51CTO和华为官方战略合作共建的鸿蒙技术社区

https://harmonyos.51cto.com/#bkwz