HarmonyOS TV和Wearable应用开发(JS篇)

HarmonyOS支持使用Java、JS、C语言进行应用开发,已支持手机(Phone)、平板(Tablet)、智慧屏(TV)、车机(Car)、智能穿戴(Wearable)、轻量级智能穿戴(Lite Wearable)、智慧视觉(Smart Vision)等多种设备。其中Phone、Tablet、TV、Wearable支持Java和JS两种语言,Car支持Java和C语言,Lite Wearable和Smart Vision仅支持JS语言,DevEco Studio提供了相应的模板。

《HarmonyOS TV和Wearable应用开发(Java篇)》 中讲解了Java应用开发的基础知识,本文将利用JS实现同样的功能。内容涉及JS UI项目结构、语法、i18n、资源文件、UI组件与布局、路由与导航、调用PA、CRUD等,展示了大部分UI组件的用法和效果。在Wearable应用中增加了访问系统能力的示例,开发了一个小游戏-数字华容道。TV和Wearable两个应用目前没有关联,可以独立启动。本文不再重复介绍Java篇中涉及的知识。

代码已上传至GitHub heroes-harmony-js,希望对初学者有所帮助。代码、文档均会与DevEco Studio和SDK同步更新。

项目概览

创建项目

老习惯,首先创建TV JS项目来了解项目的基本结构。在DevEco Studio中,进入File -> New -> New Project,然后选择TV设备、Empty Feature Ability(JS)模板:
HarmonyOS TV和Wearable应用开发(JS篇)
下一步,填写项目相关信息,创建项目:
HarmonyOS TV和Wearable应用开发(JS篇)
待下载编译完成后,可以在TV模拟器中运行应用:
HarmonyOS TV和Wearable应用开发(JS篇)
也可以进入项目的 js > default > pages > index目录,选中hml、js、css任一文件,在预览器中查看页面效果。

项目结构

HarmonyOS TV和Wearable应用开发(JS篇)
JS UI框架支持纯JavaScript、JavaScript和Java混合语言开发。TV的Empty Feature Ability(JS)模板采用了JavaScript和Java混合语言开发模式,Lite Wearable和Smart Vision的模板采用了纯JavaScript模式。

混合模式下,main目录中创建了java和js两个源码目录。
Java目录
Java目录创建了两个类MyApplication(后面重命名为HeroesApplication)和MainAbility。MyApplication继承了AbilityPackage,是模块的入口类。MainAbility继承了AceAbility,AceAbility是JS FA在HarmonyOS上运行环境的基类,继承自Ability。应用运行入口类应从AceAbility类派生。

public class MainAbility extends AceAbility {
    @Override
    public void onStart(Intent intent) {
        super.onStart(intent);
    }

    @Override
    public void onStop() {
        super.onStop();
    }
}

通过AceAbility类中setInstanceName(String name)接口设置该Ability的实例资源,若未调用则使用默认名称“default”。实例名称与config.json文件中module.js.name的值对应:

"module": {
  ...
  "js": [
    {
      "pages": [
        "pages/index/index"
      ],
      "name": "default",
      "window": {
        "designWidth": 720,
        "autoDesignWidth": false
      }
    }
  ]
}

若修改了实例名,则需在Ability的onStart()中调用setInstanceName():

public class MainAbility extends AceAbility {
    @Override
    public void onStart(Intent intent) {
        setInstanceName("newName");  // 需在super.onStart(Intent)前调用
        super.onStart(intent);
    }
}

JS目录

目录/文件 说明
app.js 用于全局JavaScript逻辑和应用生命周期管理
pages 用于存放所有组件页面
common 用于存放公共资源文件,比如:媒体资源、自定义组件、JS文件、全局样式等
i18n 用于配置不同语言的资源内容,比如:文本、图片路径等
resources 用于存放资源配置文件,比如多分辨率加载配置文件

说明:i18n和resources是保留文件夹,不可重命名。

JS UI

JS UI框架采用HML(HarmonyOS Markup Language)和CSS声明式编程语言开发页面布局和样式,采用支持ECMAScript规范的JavaScript语言开发页面业务逻辑。

扫描二维码关注公众号,回复: 12136796 查看本文章

HML、CSS和JS三者分别保存在独立的文件中,文件名必须相同,文件名即为页面名称,其中hml文件是必须的,css和js文件是可选的。页面文件名不能使用组件名称,比如:text、button等。

HML语法

HML(HarmonyOS Markup Language)是类HTML的标记语言,通过组件、事件构建页面内容。具备数据绑定、事件绑定、列表渲染、条件渲染、逻辑控制等能力。
数据绑定

<!-- index.hml -->
<div class="container">
    <text class="title">
        {{ $t('strings.hello') }} {{title}}
    </text>
</div>
// index.js
export default {
    data: {
        title: ''
    },
    onInit() {
        this.title = this.$t('strings.world');
    }
}

数据在js文件的data内声明,可以在onInit()、onShow()等方法内初始化。在hml文件内使用{{}}}绑定数据。当前JS UI还不支持双向数据绑定,只能单向显示数据。
事件绑定
有两种绑定事件的写法,如下:

<!-- 正常格式 -->
<text class="title" onclick="changeTitle">
    {{ $t('strings.hello') }} {{title}}
</text>
<!-- 缩写格式 -->
<text class="title" @click="changeTitle">
    {{ $t('strings.hello') }} {{title}}
</text>

事件函数可以接收一个事件对象参数:

export default {
    data: {
        title: ''
    },
    ...
    changeTitle(event) {
        this.title = 'HarmonyOS';
    }
}

列表渲染
使用for循环生成列表,如下:

<div>
    <div class="heroContainer">
        <!-- 默认$item代表数组中的元素, $idx代表数组中的元素索引 -->
        <text for="{{heroes}}" tid="id">
            {{$idx + 1}} {{$item.name}}
        </text>
    </div>
    <div class="heroContainer">
        <!-- 自定义元素变量名称 -->
        <text for="{{hero in heroes}}" tid="id">
            {{$idx + 1}} {{hero.name}}
        </text>
    </div>
    <div class="heroContainer">
        <!-- 自定义元素变量、索引名称 -->
        <text for="{{(index, hero) in heroes}}" tid="id">
            {{index + 1}} {{hero.name}}
        </text>
    </div>
</div>

tid属性用来加速for循环的重渲染,旨在列表中的数据变更时,提高重新渲染的效率。tid属性必须为数组元素的唯一标识,如果未指定,数组元素的索引为该元素的唯一id。tid不支持表达式。

数组定义如下:

export default {
    data: {
        heroes: [{
                     id: 1,
                     name: 'Dr Nice'
                 }, {
                     id: 2,
                     name: 'Narco'
                 }, {
                     id: 3,
                     name: 'Bombasto'
                 }, {
                     id: 4,
                     name: 'Celeritas'
                 }, {
                     id: 5,
                     name: 'Magneta'
                 }]
    }
}

增加CSS样式:

.heroContainer {
    flex-direction: column;
    align-items: flex-start;
    margin-left: 50px;
}

效果如下:
HarmonyOS TV和Wearable应用开发(JS篇)
条件渲染
条件渲染支持if/elif/else和show两种语法。两者的区别在于:前者条件为false时,组件不会渲染,也不会在vdom中构建;后者条件为false时也不渲染,但会在vdom中构建。

使用if/elif/else语法时,节点必须是兄弟节点,否则编译无法通过,如下:

<text if="{{showTitle}}" class="title">
    {{ $t('strings.hello') }} {{title}}
</text>
<text elif="{{showHarmony}}" class="title">
    Hello Harmony
</text>
<text else class="title">
    Hello World
</text>
export default {
    data: {
        title: '',
        showTitle: false,
        showHarmony: true
    }
}
<text show="{{showTitle}}" class="title">
    {{ $t('strings.hello') }} {{title}}
</text> 

Block
block用于逻辑控制,支持for和if属性,不会在vdom中创建节点:

<div>
    <list class="list">
        <block for="{{sportsWatches}}">
            <list-item class="list-item list-item-type">
                <text>
                    {{$item.type}}
                </text>
            </list-item>
            <list-item class="list-item list-item-brand">
                <text for="{{$item.brands}}">
                    {{$item}}
                </text>
            </list-item>
        </block>
    </list>
</div>
export default {
    data: {
        sportsWatches:
        [{
             type: 'Funtional',
             brands: ['Amazfit', 'Casio', 'COROS', 'EZON', 'Garmin', 'Huawei', 'Polar', 'SUUNTO', 'Vivo', 'Xiaomi']
         },
         {
             type: 'Intelligent',
             brands: ['Apple', 'Huawei', 'OPPO', 'SAMSUNG', 'Ticwatch']
         }]
    }
}
.list {
    columns: 7;
}
.list-item text {
    font-size: 17px;
    padding: 2px;
}
.list-item-type {
    justify-content: center;
}
.list-item-brand {
    column-span: 6;
}

说明:list组件的columns样式,当指定为多列时,每一列宽度相同。为达到不同宽度的效果,组合使用了columns和column-span。

效果如下:
HarmonyOS TV和Wearable应用开发(JS篇)

CSS语法

导入样式
为了模块化管理和代码复用,CSS样式文件支持 @import 语句导入 CSS 文件。

例如,在common目录中定义样式文件style.css,在index.css中导入:

/* style.css */
.title {
  font-size: 50px;
}
/* index.css */
@import '../../common/style.css';
.container {
  justify-content: center;
}

声明样式
支持在style属性内直接声明样式,在class属性指定样式名称两种方式,例如:

<list-item class="list-item list-item-type" style="background-color: gray;">
    <text>
        {{$item.type}}
    </text>
</list-item>

样式预编译
预编译提供变量、运算等功能,利用特有语法生成css,目前支持less、sass和scss三种预编译。页面样式文件和导入的外部样式文件均可使用预编译。

  • 页面样式使用预编译,例如将原index.css改为index.less:
/* index.less */
/* 定义变量 */
@colorBackground: #000000;
.container {
  background-color: @colorBackground; /* 使用当前less文件中定义的变量 */
}
  • 引用预编译文件,例如common中存在style.scss文件:
/* style.scss */
/* 定义变量 */
$colorBackground: #000000;

将原index.css改为index.scss,然后引入style.scss:

/* index.scss */
/* 引入外部scss文件 */
@import '../../common/style.scss';
.container {
  background-color: $colorBackground; /* 使用style.scss中定义的变量 */
}

伪类
css伪类用于指定要选择元素的特殊状态。支持单个伪类,也支持组合伪类,例如,:focus:checked用来设置元素的focus和checked属性同时为true时的样式。支持的伪类见下表:

伪类 支持组件 描述
:disabled 支持disabled属性的组件 disabled属性为true的元素
:focus 支持focusable属性的组件 获取focus的元素
:active 支持click事件的组件 被用户激活的元素,如:按下的按钮、激活的tab-bar页签
:waiting button waiting属性为true的元素
:checked input[type="checkbox"、type="radio"]、 switch checked属性为true的元素

示例:

.button:active {
  background-color: #888888; /*按钮被激活时,背景颜色变为#888888 */
}

媒体查询
利用媒体查询(Media Query)功能,当应用运行在不同的设备,或者屏幕动态变化(比如分屏、横竖屏切换)时,能够自动适配设备、调整布局。

@media语法规则:

@media [media-type] [and|not|only] [(media-feature)] {
    CSS-Code;
}

示例一:

/*设备屏幕为圆形时*/
@media screen and (round-screen: true) {
}

/*CSS level 3 写法,最大高度为800*/
@media (max-height: 800) {
}

/*CSS level 4 写法,与CSS level 3写法等价*/
@media (height <= 800) {
}

/*同时包含媒体类型、设备类型、分辨率的多条件复杂语句查询*/
@media screen and (device-type: tv) or (resolution < 2) {
}

示例二:

@media (device-type: tv) {
    .container {
        width: 500px;
        height: 500px;
        background-color: #fa8072;
    }
}
@media (device-type: wearable) {
    .container {
        width: 300px;
        height: 300px;
        background-color: #008b8b;
    }
}

JS语法

当前HarmonyOS支持ES6语法。

对象

  • 页面对象
属性 类型 描述
data Object/Function 页面的数据模型,类型为对象或函数,为函数时返回值必须是对象。属性名称不能以 $ 或 _ 开头,不能使用保留字。data不能与private和public重合使用。
private Object 页面的数据模型,其下的数据属性只能由当前页面修改。
public Object 页面的数据模型,其下的数据属性的行为与data一致。
$refs Object 持有注册过ref 属性的DOM元素或子组件实例的对象。
props Array/Object 用于组件之间的通信,名称必须用小写,不能以 $ 或 _ 开头,不能使用保留字。目前props的数据类型不支持Function。
computed Object 用于在读取或设置进行预先处理,计算属性的结果会被缓存。属性名不能以 $ 或 _ 开头,不能使用保留字。

通过$refs获取DOM元素:

<canvas class="canvas" ref="canvas" onkey="move"></canvas>
export default {
    move(event) {
        ...
        const canvas = this.$refs.canvas;  // 获取ref属性为canvas的DOM元素
                const context = canvas.getContext('2d');
                ...
        }
}
  • 应用对象
属性 类型 描述
$def Object 使用this.$app.$def获取在app.js中暴露的对象。

方法

  • 数据方法
属性 参数 描述
$set key: string,value: any 添加新的数据属性或者修改已有数据属性。用法:this.$set('key',value)
$delete key: string 删除数据属性。用法:this.$delete('key')
  • 公共方法
属性 参数 描述
$element id: string 获得指定id的组件对象,如未指定id,则返回根组件对象。用法:this.$element('xxx')
$root 获得顶级ViewModel实例
$parent 获得父级ViewModel实例
$child id: string 获得指定id的子级自定义组件的ViewModel实例。用法:this.$child('xxx')

通过$element 方法获取DOM元素:

<canvas class="canvas" id="canvas" onkey="move"></canvas>
export default {
    move(event) {
        ...
        const canvas = this.$element('canvas');  // 获取ref属性为canvas的DOM元素
                const context = canvas.getContext('2d');
                ...
        }
}
  • 事件方法
属性 参数 描述
$watch (data: string, callback) callback函数有两个参数,第一个为属性新值,第二个为属性旧值 观察data中的属性变化,如果属性值改变,触发绑定的事件。用法:this.$watch('key', callback)

生命周期接口

  • 页面生命周期
名称 参数 返回值 描述
onInit 页面数据初始化时触发,只触发一次。
onReady 页面创建完成时触发,只触发一次。
onShow 页面显示时触发。
onHide 页面消失时触发。
onDestroy 页面销毁时触发。
onBackPress Boolean 当用户点击返回按钮时触发。返回true表示页面自己处理返回逻辑,返回false表示使用默认的返回逻辑。
onSuspended 页面暂停时触发。
onNewRequest 当FA已启动收到新的请求后触发。
onStartContinuation Boolean 分布式能力接口,FA发起迁移时调用,应用可以在其中根据当前状态决定是否迁移
onSaveData Object 分布式能力接口,保存状态数据时调用。
onRestoreData Object 分布式能力接口,收到迁移数据,恢复数据时调用。
onCompleteContinuation code 分布式能力接口,迁移完成时调用。
  • 应用生命周期
名称 参数 返回值 描述
onCreate 应用创建时调用。
onDestroy 应用退出时调用。

i18n

JS UI国际化资源文件的存储目录为i18n,资源文件采用JSON格式,命名格式为“语言-地区.json”。例如,英文(美国)的资源文件命名为en-US.json。当没有匹配系统语言的资源文件时,默认使用en-US.json。
定义资源文件

{
  "strings": {
    "appName": "Tour of Heroes",
    "components": "HarmonyOS Components",
    "dashboard": "Dashboard",
    "heroes": "Heroes",
    "topHeroes": "Top Heroes",
    "heroSearch": "Hero Search",
    "myHeroes": "My Heroes",
    "heroName": "Hero Name",
    "add": "Add",
    "delete": "Delete",
    "deleteConfirm": "Are you sure delete {name}?",
    "deleteConfirmB": "Do you want to delete {0}?",
    "no": "NO",
    "id": "ID",
    "name": "Name",
    "details": "Details",
    "back": "Back",
    "save": "Save"
  },

  "files": {
    "image": "image/en_picture.PNG"
  }
}

资源参数支持具名参数和位置参数两种方式。

在资源文件中支持使用“zero”“one”“two”“few”“many”“other”定义不同单复数场景下的词条内容。例如,中文不区分单复数,仅存在“other”场景;英文存在“one”、“other”场景;阿拉伯语存在上述6种场景。

{
  "strings": {
    "people": {
      "one": "one person",
      "other": "{count} people"
    }
  }
}

引用资源

  • $t 简单格式化方法

$t方法既可用在hml中,也可用在js中,它有两个参数:

参数 类型 必填 描述
path string 资源路径
params Array/Object 资源参数,使用具名参数时需传递对象,使用位置参数时需传递数组。

在hml中调用:

<!-- 没有参数 -->
<text>{{ $t('strings.topHeroes') }}</text>
<!-- 具名参数 -->
<text>{{ $t('strings.deleteConfirm', { name: 'Narco' }) }}</text>
<!-- 位置参数 -->
<text>{{ $t('strings.deleteConfirmB', ['Narco']) }}</text>
<!-- 获取图片路径 -->
<image src="{{ $t('files.image') }}" class="image"></image>

在js中调用:

onInit() {
    console.info(this.$t('strings.topHeroes'));
    console.info(this.$t('strings.deleteConfirm', {name: 'Narco'}));
    console.info(this.$t('strings.deleteConfirmB', ['Narco']));
}
  • $tc单复数格式化方法

$tc是调用复数资源的方法,它有path和count两个参数:

参数 类型 必填 描述
path string 资源路径
count number 数值

示例:

console.info(this.$tc('strings.hero', 1));
console.info(this.$tc('strings.hero', 10));

获取语言和地区

  • 导入模块
import configuration from '@system.configuration';
  • 调用configuration.getLocale() 获取应用当前语言和地区:
    const locale = configuration.getLocale();
    console.info(locale.language + '-' + locale.countryOrRegion);

    资源文件

    应用可能会运行在多种不同的设备,不同的设备有不同的DPI,因此可能需要配置不同的图片资源。

屏幕密度

密度限定符 说明
ldpi 低密度屏幕(~120dpi)(0.75 * 基准密度)
mdpi 中密度屏幕(~160dpi)(基准密度)
hdpi 示高密度屏幕(~240dpi)(1.5 * 基准密度)
xhdpi 加高密度屏幕(~320dpi)(2.0 * 基准密度)
xxhdpi 超超高密度屏幕(~480dpi)(3.0 * 基准密度)
xxxhdpi 超超超高密度屏幕(~640dpi)(4.0 * 基准密度)

定义资源文件
在resources目录下定义不同DPI设备对应的资源文件,比如res-ldpi.json、res-xhdpi.json。如果当前设备的DPI不完全匹配表中定义项,那么将选择最接近的资源文件。还可以定义一个res-defaults.json资源文件,用于当对应资源文件中没有对应的资源时,将尝试在其中查找匹配项。

资源文件的内容格式如下:

{
    "image": {
        "wearable": "common/wearable.png",
        "computer": "image/computer.jpg"
     }
}

引用资源
在hml和js文件中使用$r语法引用资源:

<image src="{{ $r('image.wearable') }}" class="image"></image>
<image src="{{ computer }}" class="image"></image>
export default {
  private: {
    computer: '',
  },
  onInit() {
    this.computer = this.$r('image.computer');
  },    
}

屏幕适配

module.js.window用于定义与窗口显示相关的配置。

"module": {
  ...
  "js": [
    {
      ...
      "window": {
        "designWidth": 720,
        "autoDesignWidth": false
      }
    }
  ]
}

有两种配置方法解决屏幕适配问题:

  • 指定designWidth配置屏幕逻辑宽度
    手机和智慧屏默认为720px,智能穿戴默认为454px。所有与大小相关的样式(例如width、font-size)均以designWidth和实际屏幕宽度的比例进行缩放,例如designWidth为720时,如果设置width为100px,在实际宽度为1440物理像素的屏幕上,width实际渲染像素为200px。
  • 设置autoDesignWidth为true
    此时忽略designWidth,渲染组件和布局时按屏幕密度进行缩放。屏幕逻辑宽度由设备宽度和屏幕密度自动计算得出,在不同设备上可能不同,请使用相对布局来适配多种设备。例如:在466 * 466分辨率,320dpi的设备上,屏幕密度为2(以160dpi为基准),1px等于渲染出的2物理像素。

说明:designWidth、autoDesignWidth的设置不影响默认值计算方式和绘制结果。

路由与导航

配置路由
每个页面都要在config.json的module.js.pages中配置路由信息,例如:

"module": {
  ...
  "js": [
    {
      "pages": [
        "pages/dashboard/dashboard",
        "pages/heroes/heroes",
        "pages/hero-detail/hero-detail",
        "pages/components/components"
      ],
      ...
    }
  ]
}

路由包括页面路径和页面名称两部分。pages列表中第一个页面即是应用的首页
导航
JS API提供了页面路由router模块,使用router可以导航到指定页面、管理页面路由栈、查看当前页面状态等。

导入router模块:

import router from '@system.router';

router模块提供了以下方法:

API 参数 说明
router.push(obj: IForwardPara) {uri: string, params: object} 跳转到应用内的指定页面。
router.replace(obj: IForwardPara) {uri: string, params: object} 用应用内的某个页面替换当前页面,并销毁被替换的页面。
router.back(obj?: IBackPara) {path: string} 返回上一页面或指定的页面。
router.clear() 清空页面栈中的所有历史页面,仅保留当前页面作为栈顶页面。
router.getLength() 获取当前页面栈的页面数量。
router.getState() 获取当前页面的状态信息。

push()与replace()的区别:replace()会销毁调用页面,push()则会在页面栈中保留调用页面,调用back()可以返回。路由参数中uri的值如是"/",则跳转到首页。

示例:

import router from '@system.router';

function gotoDashboard() {
    router.push({
        uri: 'pages/dashboard/dashboard'
    });
}

function gotoHeroes() {
    router.push({
        uri: 'pages/heroes/heroes'
    });
}

function gotoDetails(id) {
    router.push({
        uri: 'pages/hero-detail/hero-detail',
        params: {
            id: id
        }
    });
}

function gotoComponents() {
    router.push({
        uri: 'pages/components/components'
    });
}

function back() {
    router.back();
}

export default {
    gotoDashboard,
    gotoHeroes,
    gotoDetails,
    gotoComponents,
    back
}

说明:

  1. 页面路由需要在页面渲染完成之后才能调用,在onInit和onReady生命周期中页面还处于渲染阶段,禁止调用页面路由方法。
  2. 页面路由栈支持的最大Page数量为32。

getState()返回值:

参数名 类型 说明
index number 当前页面在页面栈中的索引(从栈底到栈顶,index从1开始递增)。
name string 当前页面的名称,即对应文件名。
path string 当前页面的路径。

示例:

var page = router.getState();
console.info('current index = ' + page.index);
console.info('current name = ' + page.name);
console.info('current path = ' + page.path);

日志

console提供了debug、log、info、warn、error等方法打印日志信息,在DevEco Studio的“HiLog”窗口可以查看日志信息。但“HiLog”窗口不能查看log方法输出的日志,因此不要使用log()。另外,当前“HiLog”窗口显示的日志过多,刷新很快,如果不输入过滤条件是无法查看的,希望在更新版本中能解决这一问题,只输出有效的日志,或者增加过滤选项。

组件与布局

组件分类

根据组件的功能,可以将组件分为以下四大类:

组件类型 主要组件
基础组件 text、image、image-animator、progress、rating、span、marquee、divider、search、menu、chart、input、button、label、select、slider、switch、textarea、picker、picker-view
容器组件 div、list、stack、swiper、tabs、refresh、popup、dialog
媒体组件 video
画布组件 canvas

在本文的源码中展示了大部分组件,运行效果如下:
基础组件
HarmonyOS TV和Wearable应用开发(JS篇)
Image Animator和Swiper
HarmonyOS TV和Wearable应用开发(JS篇)
Picker View
picker-view滑动选择器组件支持text、multi-text、date、datetime、time等类型。
HarmonyOS TV和Wearable应用开发(JS篇)
Chart
图表组件支持线形图、柱状图、量规图。
HarmonyOS TV和Wearable应用开发(JS篇)

下面仅介绍部分组件,更多组件的用法您可以查看官方JS API组件文档。

Tabs

tabs是tab页签容器组件,它支持一个tab-bar和一个tab-content子组件。tab-bar的子组件数量决定了tab页数,tab-content应与tab-bar的子组件数量一致,多出的组件是不会显示的。

<tabs>
    <tab-bar class="tab-bar" mode="fixed">
        <text class="tab-text">
            Basic
        </text>
        <text class="tab-text">
            Image
        </text>
        <text class="tab-text">
            Picker View
        </text>
        <text class="tab-text">
            Chart
        </text>
    </tab-bar>
    <tab-content class="tab-content" scrollable="true">
        <div class="item-content">
        ...
        </div>
        <div class="item-content">
        ...
        </div>
        <div class="item-content">
        ...
        </div>
        <div class="item-content">
        ...
        </div>
    </tab-content>
</tabs>
.tabs {
    width: 100%;
}
.tab-bar {
    height: 40px;
    border-color: #007dff;
    border-width: 1px;
    border-radius: 4px;
}
.tab-text {
    color: black;
    font-size: 18px;
    text-align: center;
    width: 117px;
}
.tab-text:active {
    border-bottom: 1px #007dff;
}
.tab-content {
    width: 100%;
    height: 100%;
    justify-content: center;
}
.item-content {
    height: 100%;
    flex-direction: column;
    justify-content: flex-start;
}

Input

input组件支持text、email、date、time、number、password、button、checkbox、radio等类型。类型为text、email、date、time、number、password时,可以使用placeholder属性设置提示文本;组件获得或者失去焦点时,会自动弹出或收起软键盘,date、time、number类型时默认会显示数字键盘;可以通过enterkeytype属性设定软键盘Enter按钮的行为;可以调用showError()方法显示输入错误提示。官方文档没有给出email、date、time等类型的使用示例,组件没有格式(patttern或regex)属性,没有实现自动校验,期待进一步完善input组件的功能。

enterkeytype
enterkeytype属性有以下可选值:

  • default:默认
  • next:下一项
  • go:前往
  • done:完成
  • send:发送
  • search:搜索

除“next”外,点击Enter后会自动收起软键盘。

enterkeyclick
点击软键盘enter键后触发enterkeyclick事件,事件的value属性代表Enter Key类型,其值为数字,与enterkeytype的对应关系如下:

  • 2:go
  • 3:search
  • 4:send
  • 5:next
  • 6:default、done,或不设置enterkeytype

数据绑定
当前,JS UI不支持双向绑定,可以利用input的onchange事件来达到此目的。

示例

<input value="{{email}}" type="email" id="email-input" placeholder="Enter an email" class="input"
enterkeytype="done" onchange="emailChanged" onenterkeyclick="validateEmail"/>
const EMAIL_REG = /^([a-zA-Z0-9])(\w|\-)+@[a-zA-Z0-9]+\.([a-zA-Z]{2,4})$/;

export default {
    data: {
        email: '[email protected]'
    },
    emailChanged(event) {
        this.email = event.value;
    },
    validateEmail(event) {
        if (event.value != 6) {
            return;
        }

        if (!EMAIL_REG.test(this.email)) {
            const emailInput = this.$element('email-input');
            emailInput.showError({
                error: 'please enter a valid email address'
            });
        }
    }
}       

当输入无效的email地址,点击回车键后会显示错误信息,如下:
HarmonyOS TV和Wearable应用开发(JS篇)

Select与Menu

<select id="select" class="select" onchange="selectChanged">
    <option value="Option 1" selected="true">
        Option 1
    </option>
    <option value="Option 2">
        Option 2
    </option>
    <option value="Option 3">
        Option 3
    </option>
    <option value="Option 4">
        Option 4
    </option>
</select>
<button type="capsule" value="Show Popup Menu" onclick="showMenu" class="button"></button>
<menu id="menu" onselected="menuSelected"  title="Test Menu">
    <option value="Item 1">
        Item 1
    </option>
    <option value="Item 2">
        Item 2
    </option>
    <option value="Item 3">
        Item 3
    </option>
</menu>
import prompt from '@system.prompt';

export default {
    selectChanged(e) {
        prompt.showToast({
            message: e.newValue
        })
    },
    showMenu() {
        this.$element("menu").show({
            x: 280,
            y: 120
        });
    },
    menuSelected(e) {
        prompt.showToast({
            message: e.value
        })
    }
}       

当前select与menu组件效果雷同,两者均使用option组件生成下拉/菜单选项。不同之处是select组件在界面中显示,点击select组件会显示下拉选项;而menu组件则需要通过别的组件调用它的show()方法,或者指定target组件,点击这些组件才能显示。当前,select组件没有实现html select组件的效果,menu也不支持多级菜单。

Dialog与Prompt

dialog
dialog可以自定义弹出窗口,示例如下:

<button type="capsule" value="Show Dialog" onclick="showDialog" class="button"></button>
<dialog id="simpledialog" class="dialog-main" oncancel="cancelDialog">
    <div class="dialog-content">
        <div class="dialog-info">
            <text>
                Simple dialog
            </text>
        </div>
        <div class="dialog-btn">
            <button type="capsule" value="Cancel" onclick="cancel"></button>
            <button type="capsule" value="Confirm" onclick="confirm"></button>
        </div>
    </div>
</dialog>
import prompt from '@system.prompt';

export default {
    showDialog() {
        this.$element('simpledialog').show()
    },
    cancelDialog() {
        prompt.showToast({
            message: 'Dialog cancelled'
        })
    },
    cancel() {
        this.$element('simpledialog').close()
        prompt.showToast({
            message: 'Successfully cancelled'
        })
    },
    confirm() {
        this.$element('simpledialog').close()
        prompt.showToast({
            message: 'Successfully confirmed'
        })
    }
}       
.dialog-main {
    width: 500px;
}
.dialog-content {
    flex-direction: column;
    align-items: center;
}
.dialog-info {
    width: 400px;
    height: 160px;
    flex-direction: column;
    align-items: center;
    justify-content: space-around;
}
.dialog-btn {
    width: 400px;
    height: 120px;
    justify-content: space-around;
    align-items: center;
}

效果如下:
HarmonyOS TV和Wearable应用开发(JS篇)
prompt
prompt模块提供了showToast()和showDialog()方法,其中showToast显示简单的文本信息,会自动隐藏,showDialog显示对话框,支持以下参数:

参数名 类型 必填 说明
title string 标题文本
message string 内容文本
buttons Array 按钮数组,结构为:{text:'button', color: '#666666'}。支持1-3个按钮,其中第一个为positiveButton,第二个为negativeButton,第三个为neutralButton。
success Function 接口调用成功的回调函数
cancel Function 取消调用此接口的回调函数
complete Function 弹框退出时的回调函数

其中,success的参数含有index属性,其值为选中按钮在buttons数组中的索引,可以据此判断点击了哪一个按钮。

示例:

showPromptDialog() {
    prompt.showDialog({
        title: 'Title Info',
        message: 'Message Info',
        buttons: [
            {
                text: 'positive',
                color: '#00ff00'
            }, {
                text: 'negative',
                color: '#ff0000'
            }, {
                text: 'neutral',
                color: '#aaaaaa'
            }
        ],
        success: function (data) {
            console.info('dialog success callback,click button: ' + data.index);
        },
        cancel: function () {
            console.info('dialog cancel callback');
        },
        complete: function () {
            console.info('dialog complete callback');
        }
    });
}

效果如下:
HarmonyOS TV和Wearable应用开发(JS篇)

自定义组件

JS UI框架支持自定义组件,可根据业务需求扩展已有组件,增加属性和事件,封装成新的组件,方便复用,提高代码可读性。

自定义组件的方式与构建页面相似,同样需要编写hml、css、js。
简单的可复用组件
只是简单的组合已有组件,没有新增属性和事件。下面以navigation组件为例,演示自定义组件与使用:

  • 自定义组件
<!--navigation.hml-->
<div class="nav">
    <text class="title">
        {{ $t('strings.appName') }}
    </text>
    <div class="row-40">
        <button type="capsule" value="{{ $t('strings.dashboard') }}" onclick="gotoDashboard" class="nav-button"/>
        <button type="capsule" value="{{ $t('strings.heroes') }}" onclick="gotoHeroes" class="nav-button"/>
    </div>
</div>
// navigation.js
import routing from '../routing.js';

export default {
    gotoDashboard: function () {
        routing.gotoDashboard();
    },
    gotoHeroes: function () {
        routing.gotoHeroes();
    }
}
/*navigation.css*/
@import '../style.css';

.nav {
    height: 80px;
    margin-left: 4px;
    flex-direction: column;
    justify-content: flex-start;
}
.nav-button {
    height: 30px;
    width: 120px;
    margin-top: 10px;;
    margin-right: 10px;
    background-color: lightgray;
    font-size: 16px;
    text-color: black;
}
  • 引用自定义组件

使用element标签引入自定义组件并命名,然后在布局中使用该名称即可:

<element name="nav" src="../../common/navigation/navigation.hml"></element>
<div class="container">
    <nav></nav>
        ...
</div>      

带有属性和事件的自定义组件

  • 自定义组件

删除数据时常需用户确认,下面自定义了含有确认对话框的删除按钮:

<!--delete-button.hml-->
<div class="container">
    <button type="text" value="X" class="detete-button" onclick="deleteObject"></button>
    <dialog id="deletedialog" class="dialog-main">
        <div class="dialog-content">
            <div class="dialog-info">
                <text>
                    {{dialogInfo}}
                </text>
            </div>
            <div class="dialog-button">
                <button type="capsule" value="No" onclick="closeDialog"></button>
                <button type="capsule" value="Yes" onclick="exeDelete"></button>
            </div>
        </div>
    </dialog>
</div>

在props中定义组件属性,调用$emit()方法触发组件事件:

// delete-button.js
const DELETE_DIALOG_ID = 'deletedialog';
const DELETE_EVENT_NAME = 'delete';

export default {
    props: {
        confirm: true,
        entityid: 0,
        keyword: ''
    },
    data: {
        dialogInfo: '',
    },
    onInit() {
        this.dialogInfo = this.$t('strings.deleteConfirm', {
            name: this.keyword
        });
    },
    deleteObject() {
        if (this.confirm) {
            this.showDialog();
        } else {
            this.deleteEntity();
        }
    },
    showDialog() {
        this.$element(DELETE_DIALOG_ID).show();
    },
    closeDialog() {
        this.$element(DELETE_DIALOG_ID).close();
    },
    exeDelete() {
        this.deleteEntity();
        this.closeDialog();
    },
    deleteEntity() {
        this.$emit(DELETE_EVENT_NAME, {
            id: this.entityid
        });
    }
}
/*delete-button.css*/
.container {
    width: 50px;
    height: 22px;
}
.detete-button {
    width: 50px;
    font-size: 14px;
    text-color: black;
}
.dialog-main {
    width: 300px;
}
.dialog-content {
    width: 260px;
    flex-direction: column;
    align-items: center;
}
.dialog-info {
    height: 77px;
    align-items: center;
    flex-direction: column;
    justify-content: space-around;
}
.dialog-info text {
    font-size: 20px;
}
.dialog-button {
    height: 77px;
    align-items: center;
    justify-content: space-around;
}
  • 引用自定义组件
<element name="delete-button" src="../../common/deletebutton/delete-button.hml"></element>
...
<delete-button confirm="true" entityid="{{$item.id}}" keyword="{{$item.name}}"  @delete="deleteHero()"></delete-button>

调用PA

JS UI框架提供了JS FA(Feature Ability)调用Java PA(Particle Ability)的机制,提供调用PA和订阅PA的能力。

当前提供Ability和Internal Ability两种调用方式,开发者可以根据业务场景选择适合的方式。

  • Ability:拥有独立的Ability生命周期,FA使用远端进程通信拉起并请求PA服务,适用于基本服务供多FA调用或者服务在后台独立运行的场景。
  • Internal Ability:与FA共进程,采用内部函数调用的方式和FA进行通信,适用于对服务响应时延要求较高的场景。该方式下PA不支持其他FA访问调用。

FA调用PA接口
FA端提供以下三个调用PA的JS接口:

  • FeatureAbility.callAbility(param: CallAbilityParam):调用PA,返回结果是JSON字符串构成的Promise对象。
  • FeatureAbility.subscribeAbilityEvent(param: SubscribeAbilityEventParam, func: Function):订阅PA
  • FeatureAbility.unsubscribeAbilityEvent(param: SubscribeAbilityEventParam):取消订阅PA

PA端提供以下两类接口:

  • Ability调用接口:RemoteObject.onRemoteRequest(int code, MessageParcel data, MessageParcel reply, MessageOption option)
  • Internal Ability调用接口:AceInternalAbility.AceInternalAbilityHandler.onRemoteRequest(int code, MessageParcel data, MessageParcel reply, MessageOption option)

Ability和Internal Ability两者要实现的onRemoteRequest()接口的参数和返回值是完全一致的:

  • code:请求码,Ability据此判断执行的业务方法
  • data:请求数据/参数,当前仅支持json字符串格式
  • reply:返回结果,当前仅支持String格式
  • option:指示操作是同步还是异步方式
  • 返回值:操作成功返回true,否则返回false。

个人观点,这是一种古老的接口设计方案,不适合大规模的应用。

Ability和InternalAbility差异项

差异项 Ability InternalAbility
是否需要在config.json的abilities中添加声明 需要(有独立的生命周期) 不需要(和FA共生命周期)
是否需要在FA中注册 不需要 需要
继承的类 ohos.aafwk.ability.Ability ohos.ace.ability.AceInternalAbility
是否允许被其他FA访问调用
同步/异步返回结果 仅支持同步 同步、异步均支持

Ability

本文仍使用ORM数据库,将《Java篇》中的相关代码拷贝过来,这里不再介绍ORM数据库相关知识。

Ability和Internal Ability的onRemoteRequest()接口本质上是一致的,为了演示两者的用法时能复用代码,增加共用的HeroService类,如下:

HeroService
HeroService根据请求码调用HeroRepository的方法执行CRUD操作。

package io.itrunner.heroes.data;

import ohos.app.Context;
import ohos.rpc.MessageParcel;

import static ohos.utils.zson.ZSONObject.stringToClass;

public class HeroService {
    private static final int INSERT = 1001;
    private static final int UPDATE = 1003;
    private static final int DELETE = 1004;
    private static final int QUERY_TOP4 = 10021;
    private static final int QUERY_ALL = 10022;
    private static final int GET_ONE = 10023;
    private static final int QUERY_BY_NAME = 10024;

    private HeroRepository repository;

    public HeroService(Context context) {
        repository = new HeroRepository(context);
    }

    public Object onRemoteRequest(int code, MessageParcel data) {
        Object result = null;

        switch (code) {
            case INSERT: {
                repository.insert(toHero(data));
                break;
            }
            case UPDATE: {
                repository.update(toHero(data));
                break;
            }
            case DELETE: {
                Hero param = toHero(data);
                repository.delete(param.getId());
                break;
            }
            case QUERY_TOP4: {
                result = repository.queryTop4();
                break;
            }
            case QUERY_ALL: {
                result = repository.queryAll();
                break;
            }
            case GET_ONE: {
                Hero param = toHero(data);
                result = repository.getOne(param.getId());
                break;
            }
            case QUERY_BY_NAME: {
                Hero param = toHero(data);
                result = repository.queryByName(param.getName());
                break;
            }
            default: {
                throw new IllegalArgumentException();
            }
        }
        return result;
    }

    private static Hero toHero(MessageParcel data) {
        String zson = data.readString();
        return stringToClass(zson, Hero.class);
    }
}

定义HeroServiceAbility
右键点击工程目录,在弹出的菜单中依次点击 New > Ability > Empty Service Ability,新建HeroServiceAbility。创建成功后会自动在config.json的abilities中配置如下内容:

{
  "name": "io.itrunner.heroes.HeroServiceAbility",
  "icon": "$media:icon",
  "description": "$string:serviceability_description",
  "type": "service"
}

Ability方式需要继承ohos.aafwk.ability.Ability,并且必须重写onConnect方法。FA首次连接PA时调用onConnect,并返回响应FA请求的IRemoteObject对象。定义IRemoteObject的实现类需要继承RemoteObject并实现onRemoteRequest()方法。完整代码如下:

package io.itrunner.heroes;

import io.itrunner.heroes.data.HeroService;
import ohos.aafwk.ability.Ability;
import ohos.aafwk.content.Intent;
import ohos.hiviewdfx.HiLog;
import ohos.hiviewdfx.HiLogLabel;
import ohos.rpc.*;

import java.util.HashMap;
import java.util.Map;

import static ohos.utils.zson.ZSONObject.toZSONString;

public class HeroServiceAbility extends Ability {
    private static final String TAG = HeroServiceAbility.class.getSimpleName();
    private static final HiLogLabel LOG_LABEL = new HiLogLabel(HiLog.LOG_APP, 0x00102, TAG);

    private HeroRemote remote = new HeroRemote();

    private HeroService service = new HeroService(this);

    @Override
    protected void onStart(Intent intent) {
        HiLog.info(LOG_LABEL, "onStart");
        super.onStart(intent);
    }

    @Override
    public IRemoteObject onConnect(Intent intent) {
        HiLog.info(LOG_LABEL, "onConnect");
        super.onConnect(intent);
        return remote.asObject();
    }

    class HeroRemote extends RemoteObject implements IRemoteBroker {
        private static final int ERROR = -1;
        private static final int SUCCESS = 0;

        HeroRemote() {
            super("HeroRemote");
        }

        @Override
        public boolean sendRequest(int code, MessageParcel data, MessageParcel reply, MessageOption option) throws RemoteException {
            return super.sendRequest(code, data, reply, option);
        }

        @Override
        public boolean onRemoteRequest(int code, MessageParcel data, MessageParcel reply, MessageOption option) {
            HiLog.info(LOG_LABEL, "onRemoteRequest");
            Map<String, Object> zsonResult = new HashMap<>();
            try {
                Object result = service.onRemoteRequest(code, data);

                zsonResult.put("code", SUCCESS);
                if (result != null) {
                    zsonResult.put("result", result);
                }
                reply.writeString(toZSONString(zsonResult));
                return true;
            } catch (Exception e) {
                HiLog.error(LOG_LABEL, e.getMessage());

                zsonResult.put("code", ERROR);
                zsonResult.put("message", e.getMessage());
                reply.writeString(toZSONString(zsonResult));
                return false;
            }
        }

        @Override
        public IRemoteObject asObject() {
            return this;
        }
    }
}

Internal Ability

定义HeroServiceInternalAbility
Internal Ability需要继承AceInternalAbility,实现AceInternalAbilityHandler的onRemoteRequest()方法,并且调用AceInternalAbility的setInternalAbilityHandler()方法注册AceInternalAbilityHandler。Internal Ability支持同步和异步两种方式,需要根据请求参数分别处理。完整代码如下:

package io.itrunner.heroes;

import io.itrunner.heroes.data.HeroService;
import ohos.ace.ability.AceInternalAbility;
import ohos.app.AbilityContext;
import ohos.hiviewdfx.HiLog;
import ohos.hiviewdfx.HiLogLabel;
import ohos.rpc.IRemoteObject;
import ohos.rpc.MessageOption;
import ohos.rpc.MessageParcel;
import ohos.rpc.RemoteException;
import ohos.utils.zson.ZSONObject;

import static ohos.utils.zson.ZSONObject.toZSONString;

public class HeroServiceInternalAbility extends AceInternalAbility {
    private static final String TAG = HeroServiceInternalAbility.class.getSimpleName();
    private static final HiLogLabel LOG_LABEL = new HiLogLabel(HiLog.LOG_APP, 0x00102, TAG);

    private static final String BUNDLE_NAME = "io.itrunner.heroes";
    private static final String ABILITY_NAME = "io.itrunner.heroes.HeroServiceInternalAbility";
    private static final int ERROR = -1;
    private static final int SUCCESS = 0;

    private static HeroServiceInternalAbility instance;
    private HeroService heroService;

    /* 如果多个Ability实例都需要注册当前InternalAbility实例,需要更改构造函数,设定自己的bundleName和abilityName */
    public HeroServiceInternalAbility() {
        super(BUNDLE_NAME, ABILITY_NAME);
    }

    private boolean onRemoteRequest(int code, MessageParcel data, MessageParcel reply, MessageOption option) {
        HiLog.info(LOG_LABEL, "onRemoteRequest");

        ZSONObject zsonResult = new ZSONObject();
        try {
            Object result = heroService.onRemoteRequest(code, data);

            zsonResult.put("code", SUCCESS);
            if (result != null) {
                zsonResult.put("result", result);
            }
        } catch (Exception e) {
            HiLog.error(LOG_LABEL, e.getMessage());

            zsonResult.put("code", ERROR);
            zsonResult.put("message", e.getMessage());
            reply.writeString(toZSONString(zsonResult));
            return false;
        }

        // SYNC
        if (option.getFlags() == MessageOption.TF_SYNC) {
            reply.writeString(toZSONString(zsonResult));
        } else {
            // ASYNC
            MessageParcel responseData = MessageParcel.obtain();
            responseData.writeString(toZSONString(zsonResult));

            IRemoteObject remoteReply = reply.readRemoteObject();
            try {
                remoteReply.sendRequest(0, responseData, MessageParcel.obtain(), new MessageOption());
                responseData.reclaim();
            } catch (RemoteException exception) {
                HiLog.error(LOG_LABEL, exception.getMessage());
                return false;
            }
        }
        return true;
    }

    /**
     * Internal ability registration
     */
    public static void register(AbilityContext abilityContext) {
        instance = new HeroServiceInternalAbility();
        instance.onRegister(abilityContext);
    }

    private void onRegister(AbilityContext abilityContext) {
        this.heroService = new HeroService(abilityContext);
        this.setInternalAbilityHandler(this::onRemoteRequest);
    }

    /**
     * Internal ability deregistration
     */
    public static void deregister() {
        instance.onDeregister();
    }

    private void onDeregister() {
        heroService = null;
        this.setInternalAbilityHandler(null);
    }
}

注册Internal Ability
InternalAbility必须在FA中注册。

public class MainAbility extends AceAbility {
    private static final String TAG = MainAbility.class.getSimpleName();
    private static final HiLogLabel LOG_LABEL = new HiLogLabel(HiLog.LOG_APP, 0x00102, TAG);

    @Override
    public void onStart(Intent intent) {
        HiLog.info(LOG_LABEL, "onStart");

        if (!existsDatabase(this)) {
            createDatabase(this);
            initDatabase(this);
        }

        // 注册, 如果需要在Page初始化时调用AceInternalAbility,需要在super.onStart之前进行注册
        HeroServiceInternalAbility.register(this);
        super.onStart(intent);

        setAbilitySliceAnimator(null);
    }

    @Override
    public void onStop() {
        HiLog.info(LOG_LABEL, "onStop");
        HeroServiceInternalAbility.deregister();
        super.onStop();
    }
}

FeatureAbility

FA调用Ability和InternalAbility均使用FeatureAbility.callAbility(param: CallAbilityParam)方法。CallAbilityParam包含以下属性:

  • bundleName Bundle name
  • abilityName Ability name
  • messageCode 请求码,必选项,需要与PA保持一致
  • abilityType Ability类型,必选项,可选值:0 Ability,1 Internal ability
  • data 请求数据,可选项
  • syncOption 可选项,可选值:0 同步(默认值),1 异步

调用PA示例 - service.js
下面的示例默认使用InternalAbility,若要使用Ability请将常量USING_INTERNAL的值改为false。

// abilityType: 0-Ability; 1-Internal Ability
const ABILITY_TYPE_EXTERNAL = 0;
const ABILITY_TYPE_INTERNAL = 1;
// syncOption(Optional, default sync): 0-Sync; 1-Async
const SYNC = 0;
const ASYNC = 1;
const BUNDLE_NAME = 'io.itrunner.heroes';
const ABILITY_NAME_EXTERNAL = 'io.itrunner.heroes.HeroServiceAbility'
const ABILITY_NAME_INTERNAL = 'io.itrunner.heroes.HeroServiceInternalAbility'
const MESSAGE_CODE_INSERT = 1001;
const MESSAGE_CODE_UPDATE = 1003;
const MESSAGE_CODE_DELETE = 1004;
const MESSAGE_CODE_QUERY_TOP4 = 10021;
const MESSAGE_CODE_QUERY_ALL = 10022;
const MESSAGE_CODE_GET_ONE = 10023;
const MESSAGE_CODE_QUERY_BY_NAME = 10024;
const USING_INTERNAL = true;

function queryTop4() {
    let param = createParam(MESSAGE_CODE_QUERY_TOP4, {});
    return FeatureAbility.callAbility(param);
}

function queryAll() {
    let param = createParam(MESSAGE_CODE_QUERY_ALL, {});
    return FeatureAbility.callAbility(param);
}

function getOne(id) {
    let param = createParam(MESSAGE_CODE_GET_ONE, {
        'id': id
    });
    return FeatureAbility.callAbility(param);
}

function queryByName(name) {
    let param = createParam(MESSAGE_CODE_QUERY_BY_NAME, {
        'name': name
    });
    return FeatureAbility.callAbility(param);
}

function addHero(hero) {
    let param = createParam(MESSAGE_CODE_INSERT, hero);
    return FeatureAbility.callAbility(param);
}

function updateHero(hero) {
    let param = createParam(MESSAGE_CODE_UPDATE, hero);
    return FeatureAbility.callAbility(param);
}

function deleteHero(id) {
    let param = createParam(MESSAGE_CODE_DELETE, {
        'id': id
    });
    return FeatureAbility.callAbility(param);
}

function createParam(messageCode, data) {
    let param = {};

    param.bundleName = BUNDLE_NAME;
    param.messageCode = messageCode;
    param.data = data;
    param.syncOption = SYNC;

    if (USING_INTERNAL) {
        param.abilityName = ABILITY_NAME_INTERNAL;
        param.abilityType = ABILITY_TYPE_INTERNAL;
    } else {
        param.abilityName = ABILITY_NAME_EXTERNAL;
        param.abilityType = ABILITY_TYPE_EXTERNAL;
    }

    return param;
}

export default {
    queryTop4,
    queryAll,
    getOne,
    queryByName,
    addHero,
    updateHero,
    deleteHero,
}

在页面js中引入service.js即可调用,如下:

import prompt from '@system.prompt';
import service from '../../common/service.js';

export default {
    data: {
        topHeroes: []
    },
    onShow() {
        service.queryTop4().then(result => {
            let ret = JSON.parse(result);
            if (ret.code == 0) {
                this.topHeroes = ret.result;
            } else {
                console.error(result);
                prompt.showToast({
                    message: ret.message
                });
            }
        });
    }
}

Hero UI

啰嗦了那么多,终于步入主题了。同《Java篇》一样,依然开发Dashboard、Hero列表、Hero详情三个页面。前期我们已经开发了PA、Router、导航组件、删除按钮组件等,本部分仅涉及CRUD的页面开发,更进一步的了解JS UI组件的使用。

Dashboard

HarmonyOS TV和Wearable应用开发(JS篇)
Dashboard页面显示TOP 4英雄榜,点击hero名字可进入Hero详情页面,可根据名字查询hero。其中,Hero查询列表使用了list组件,当数据超出组件高度时,上下拖动可以查看所有数据。
dashboard.hml
dashboard.hml中引入了navigation组件。

<element name="nav" src="../../common/navigation/navigation.hml"></element>
<div class="container">
    <nav></nav>
    <div class="row-40 center">
        <text class="title">
            {{ $t('strings.topHeroes') }}
        </text>
    </div>
    <div class="row-40">
        <text for="{{topHeroes}}" class="heroes" onclick="gotoDetails({{$item.id}})" tid="id">
            {{$item.name}}
        </text>
    </div>
    <div class="row-40">
        <text class="normal-text">
            {{ $t('strings.heroSearch') }}
        </text>
    </div>
    <div class="row-40">
        <search class="search" onsubmit="search"></search>
    </div>
    <list class="list" scrolleffect="spring">
        <list-item for="{{heroes}}">
            <text class="normal-text" onclick="gotoDetails({{$item.id}})">
                {{$item.name}}
            </text>
        </list-item>
    </list>
    <div class="row-40 right">
        <image src="common/images/icon.png" onclick="gotoComponents" class="icon"></image>
    </div>
</div>

dashboard.js
dashboard.js导入了routing.js和service.js,分别实现导航和调用PA功能。

import prompt from '@system.prompt';
import routing from '../../common/routing.js';
import service from '../../common/service.js';

export default {
    data: {
        topHeroes: [],
        heroes: [],
    },
    onShow() {
        service.queryTop4().then(result => {
            let ret = JSON.parse(result);
            if (ret.code == 0) {
                this.topHeroes = ret.result;
            } else {
                console.error(result);
                prompt.showToast({
                    message: ret.message
                });
            }
        });
    },
    search(event) {
        service.queryByName(event.text).then(result => {
            let ret = JSON.parse(result);
            if (ret.code == 0) {
                this.heroes = ret.result;
            }
        });
    },
    gotoDetails(id) {
        routing.gotoDetails(id);
    },
    gotoComponents() {
        routing.gotoComponents();
    },
}

dashboard.css
dashboard.css导入了全局样式style.css,为右下角的image组件定义了旋转的动画样式。

@import '../../common/style.css';
.heroes {
    background-color: #607D8B;
    border-radius: 4px;
    color: black;
    font-size: 16px;
    text-align: center;
    height: 40px;
    width: 160px;
    margin-right: 10px;
}
.search {
    border: 1px solid black;
    border-radius: 4px;
    color: black;
    font-size: 16px;
    width: 200px;
    height: 30px;
}
.list {
    width: 200px;
    height: 100px;
    background-color: white;
}
.icon {
    width: 30px;
    height: 30px;
    margin-right: 10px;
    animation-name: rotate;
    animation-duration: 2s;
    animation-iteration-count: infinite;
    animation-timing-function: linear;
}
@keyframes rotate {
    from {
        transform: rotate(0deg);
    }
    to {
        transform: rotate(360deg);
    }
}

Hero列表

HarmonyOS TV和Wearable应用开发(JS篇)
(表格行间有空白,最初运行时没有这个问题,应是近期模拟器调整造成的bug)

Hero列表页面可以增加、删除hero,点击hero名字将进入Hero详情页面,上下拖动表格可以查看所有数据。

heroes.hml
heroes.hml引入了navigation和delete-button组件。目前还没有好的方式定义表格边框,下面代码使用了多个样式。

<element name="nav" src="../../common/navigation/navigation.hml"></element>
<element name="delete-button" src="../../common/deletebutton/delete-button.hml"></element>
<div class="container">
    <nav></nav>
    <div class="row-40">
        <text class="title">
            {{ $t('strings.myHeroes') }}
        </text>
    </div>
    <div class="row-40">
        <text class="normal-text">
            {{ $t('strings.heroName') }}
        </text>
        <input type="text" value="{{heroName}}" class="input" enterkeytype="done" maxlength="30"
                onchange="nameChanged"></input>
        <button type="capsule" value="{{$t('strings.add')}}" class="button" onclick="addHero"></button>
    </div>
    <list class="list" itemscale="false" scrolleffect="spring">
        <list-item class="list-item-header border-full">
            <text class="col-40 border-right">
                {{$t('strings.no')}}
            </text>
            <text class="col-400 border-right">
                {{$t('strings.name')}}
            </text>
            <text class="col-50">
                {{$t('strings.delete')}}
            </text>
        </list-item>
        <list-item for="{{heroes}}" class="list-item border-no-top">
            <text class="col-40 border-right">
                {{$idx + 1}}
            </text>
            <text class="col-400 border-right" onclick="gotoDetails({{$item.id}})">
                {{$item.name}}
            </text>
            <delete-button confirm="true" entityid="{{$item.id}}" keyword="{{$item.name}}"
                    @delete="deleteHero()"></delete-button>
        </list-item>
    </list>
</div>

heroes.js

import routing from '../../common/routing.js';
import service from '../../common/service.js';

export default {
    data: {
        heroes: [],
        heroName: ''
    },
    onShow() {
        service.queryAll().then(result => {
            let ret = JSON.parse(result);
            if (ret.code == 0) {
                this.heroes = ret.result;
            }
        });
    },
    addHero() {
        service.addHero({
            name: this.heroName
        }).then(() => {
            this.heroName = '';
            this.onShow();
        });
    },
    deleteHero(e) {
        service.deleteHero(e.detail.id).then(() => {
            this.onShow();
        });
    },
    nameChanged(event) {
        this.heroName = event.text;
    },
    gotoDetails(id) {
        routing.gotoDetails(id);
    },
}

heroes.css

@import '../../common/style.css';

.list {
    width: 500px;
    height: 200px;
    margin-top: 10px;
    background-color: white;
    flex-direction: column;
}
.list-item-header, .list-item {
    width: 500px;
    height: 22px;
    align-items: center;
    position: relative;
    left: -25px;
}
.list-item-header {
    background-color: #fafafa;
}
.list-item-header text, .list-item text {
    color: black;
    font-size: 14px;
}
.list-item-header text {
    text-align: center;
}
.list-item text {
    padding-left: 4px;
}

Hero详情

HarmonyOS TV和Wearable应用开发(JS篇)
本页面较简单,只提供修改英雄名字的功能。
hero-detail.hml

<element name="nav" src="../../common/navigation/navigation.hml"></element>
<div class="container">
    <nav></nav>
    <div class="row-40">
        <text class="title">
            {{ $t('strings.details') }}
        </text>
    </div>
    <div class="row-40">
        <label class="label">
            {{ $t('strings.id') }}
        </label>
        <text class="normal-text">
            {{hero.id}}
        </text>
    </div>
    <div class="row-40">
        <label target="name" class="label">
            {{ $t('strings.name') }}
        </label>
        <input value="{{hero.name}}" type="text" id="name" class="input" enterkeytype="done" maxlength="30"
                onchange="nameChange"></input>
    </div>
    <div class="row-40">
        <button type="capsule" value="{{ $t('strings.back')}}" onclick="back" class="button"></button>
        <button type="capsule" value="{{ $t('strings.save')}}" onclick="save" class="button"></button>
    </div>
</div>

hero-detail.js
导航到本页面时需传入hero的id参数,在js方法中可以直接使用。

import routing from '../../common/routing.js';
import service from '../../common/service.js';

export default {
    data: {
        hero: {}
    },
    onShow() {
        service.getOne(this.id).then(result => {
            let ret = JSON.parse(result);
            if (ret.code == 0) {
                this.hero = {
                    id: ret.result.id,
                    name: ret.result.name
                };
            }
        });
    },
    save() {
        service.updateHero(this.hero).then(result => {
            let ret = JSON.parse(result);
            if (ret.code == 0) {
                this.back();
            }
        })
    },
    nameChange(event) {
        this.hero.name = event.text;
    },
    back() {
        routing.back();
    }
}

说明:ORM数据库的Entity要继承OrmObject,在PA中调用ZSONObject.toZSONString()方法将Entity转换为JSON字符串,内容如下:

{"entityName":"hero","id":4,"name":"Celeritas","objectId":{"alias":"HeroStore","entityName":"hero"},"rowId":4,"storeAlias":"HeroStore"}

JSON字符串中不只含有Hero的"id"、"name"属性,还有"rowId"、"entityName"、"objectId"、"storeAlias"等内容,因此在保存数据时不能直接使用JSON.parse() 的结果。显然,JSON中含有很多冗余数据,希望新的SDK有好的解决办法。
hero-detail.css

@import '../../common/style.css';

Wearable

本章介绍Wearable应用中JS UI的组件与布局、系统信息读取,然后开发一个数字华容道的小游戏。本章与TV应用无关。

创建Wearable Module

右键点击工程根目录,在弹出菜单中点击New > Module,然后在New Project Module界面中选择Wearable > Empty Feature Ability(JS):
HarmonyOS TV和Wearable应用开发(JS篇)
在Module配置页面填写Module的基本信息,创建Module:
HarmonyOS TV和Wearable应用开发(JS篇)

路由

Wearable应用共涉及heroes(Hero列表)、sleep、system(系统信息)、huarong(数字华容道游戏)、setting(游戏设置)五个页面,下面是导航到各页面的路由方法:
routing.js

import router from '@system.router';

function gotoHeroes() {
    router.push({
        uri: 'pages/heroes/heroes',
    });
}

function gotoSleep() {
    router.push({
        uri: 'pages/sleep/sleep'
    });
}

function gotoSystem() {
    router.push({
        uri: 'pages/system/system'
    });
}

function gotoHuarong(level, cols) {
    if (level && cols) {
        router.replace({
            uri: 'pages/huarong/huarong',
            params: {
                level: level,
                cols: cols
            }
        });
    } else {
        router.replace({
            uri: 'pages/huarong/huarong'
        });
    }
}

function gotoSetting() {
    router.replace({
        uri: 'pages/setting/setting'
    });
}

function back() {
    router.back();
}

export default {
    gotoHeroes,
    gotoSleep,
    gotoSystem,
    gotoHuarong,
    gotoSetting,
    back
}

两个简单页面

Hero列表
HarmonyOS TV和Wearable应用开发(JS篇)
使用list组件显示Hero名单,支持上下滑动,中心位置的条目会放大。另外有三个图片按钮,点击可以进入其他页面。

<!--heroes.hml-->
<div class="container">
    <image src="common/images/game.png" class="image" onclick="gotoHuarong"></image>
    <list class="list" itemcenter="true" itemscale="true" initialindex="4">
        <list-item for="{{heroes}}" class="list-item">
            <text class="normal-text">
                {{$item}}
            </text>
        </list-item>
    </list>
    <div class="right">
        <image src="common/images/sleep.png" class="image" onclick="gotoSleep"></image>
        <image src="common/images/system.png" class="image" onclick="gotoSystem"></image>
    </div>
</div>
// heroes.js
import routing from '../../common/routing.js';

export default {
    data: {
        heroes: ['Dr Nice', 'Narco', 'Bombasto', 'Celeritas', 'Magneta', 'RubberMan', 'Dynama', 'Dr IQ', 'Magma', 'Tornado']
    },
    gotoHuarong() {
        routing.gotoHuarong();
    },
    gotoSleep() {
        routing.gotoSleep();
    },
    gotoSystem() {
        routing.gotoSystem();
    }
}
/* heroes.css */
.container {
    flex-direction: row;
    justify-content: center;
    align-items: center;
}
.list {
    margin: 20px;
    width: 280px;
}
.list-item {
    height: 80px;
    justify-content: center;
}
.image {
    width: 48px;
    height: 48px;
    margin-top: 20px;
    margin-bottom: 20px;
}
.right {
    width: 60px;
    height: 180px;
    flex-direction: column;
    justify-content: flex-start;
}

Sleep
HarmonyOS TV和Wearable应用开发(JS篇)
本页面只显示了一组固定的数据。

<!--sleep.hml-->
<div class="container">
    <image src="common/images/sleep.png" class="image"></image>
    <text class="text-normal">
        {{ $t('strings.sleep') }}
    </text>
    <div class="row-180">
        <text class="text-large">
            {{sleepHours}}
        </text>
        <text class="text-normal">
            {{ $t('strings.hour') }}
        </text>
        <text class="text-large">
            {{sleepMinutes}}
        </text>
        <text class="text-normal">
            {{ $t('strings.minute') }}
        </text>
    </div>
    <div class="row-50">
        <text class="text-normal">
            {{ $t('strings.goal') }}
        </text>
        <text class="text-midium">
            {{sleepGoal}}
        </text>
        <text class="text-normal">
            {{ $t('strings.hour') }}
        </text>
    </div>
</div>
// sleep.js
export default {
    data: {
        sleepHours: 6,
        sleepMinutes: 30,
        sleepGoal: 8
    }
}
/* sleep.css */
.container {
    flex-direction: column;
    justify-content: center;
    align-items: center;
}
.row-50 {
    height: 50px;
    justify-content: center;
}
.row-180 {
    height: 180px;
    justify-content: center;
}
.image {
    width: 64px;
    height: 64px;
}
.text-normal {
    font-size: 32px;
}
.text-midium {
    font-size: 42px;
}
.text-large {
    font-size: 116px;
}

系统信息

HarmonyOS TV和Wearable应用开发(JS篇)
本页面调用系统接口显示电量、屏幕亮度、设备、地理位置、网络等信息。

配置权限
其中调用地理位置、网络接口需要在config.json中配置权限:

"module": {
  "package": "io.itrunner.heroes.wearable",
  "name": ".WearableApplication",
  "reqPermissions": [
    {
      "name": "ohos.permission.LOCATION"
    },
    {
      "name": "ohos.permission.GET_WIFI_INFO"
    },
    {
      "name": "ohos.permission.GET_NETWORK_INFO"
    }
  ],
  ...
}

在运行时需要经过用户授权才能访问地理位置、网络接口:
HarmonyOS TV和Wearable应用开发(JS篇)
system.hml

<div class="container">
    <div class="row-top">
        <div class="grid-top-left">
            <div class="right">
                <text>
                    电量
                </text>
            </div>
            <div>
                <text>
                    {{ batteryLevel }}
                </text>
            </div>
            <div class="right">
                <text>
                    充电中
                </text>
            </div>
            <div>
                <text>
                    {{ batteryCharging }}
                </text>
            </div>
        </div>
        <div class="grid-top-right">
            <div>
                <text>
                    亮度
                </text>
            </div>
            <div>
                <text>
                    {{ brightness }}
                </text>
            </div>
            <div>
                <text>
                    模式
                </text>
            </div>
            <div>
                <text>
                    {{ brightnessMode }}
                </text>
            </div>
        </div>
    </div>
    <div class="grig-middle">
        <div class="right">
            <text>
                品牌
            </text>
        </div>
        <div>
            <text>
                {{deviceBrand}}
            </text>
        </div>
        <div>
            <text>
                屏幕形状
            </text>
        </div>
        <div>
            <text>
                {{deviceScreenShape}}
            </text>
        </div>
        <div class="right">
            <text>
                生产商
            </text>
        </div>
        <div>
            <text>
                {{deviceManufacturer}}
            </text>
        </div>
        <div>
            <text>
                语言
            </text>
        </div>
        <div>
            <text>
                {{deviceLanguage}}
            </text>
        </div>
        <div class="right">
            <text>
                型号
            </text>
        </div>
        <div>
            <text>
                {{deviceModel}}
            </text>
        </div>
        <div>
            <text>
                地区
            </text>
        </div>
        <div>
            <text>
                {{deviceRegion}}
            </text>
        </div>
    </div>
    <div class="row-bottom">
        <div class="grid-bottom-left">
            <div class="right">
                <text>
                    定位精度
                </text>
            </div>
            <div>
                <text>
                    {{ locationAccuracy }}
                </text>
            </div>
            <div class="right">
                <text>
                    经度
                </text>
            </div>
            <div>
                <text>
                    {{ locationLongitude }}
                </text>
            </div>
            <div class="right">
                <text>
                    纬度
                </text>
            </div>
            <div>
                <text>
                    {{ locationLatitude }}
                </text>
            </div>
        </div>
        <div class="grid-bottom-right">
            <div>
                <text>
                    定位类型
                </text>
            </div>
            <div>
                <text>
                    {{ locationType }}
                </text>
            </div>
            <div>
                <text>
                    网络类型
                </text>
            </div>
            <div>
                <text>
                    {{ networkType }}
                </text>
            </div>
            <div>
                <text>
                    流量计费
                </text>
            </div>
            <div>
                <text>
                    {{ networkMetered }}
                </text>
            </div>
        </div>
    </div>
    <div class="row-footer">
        <text>
            {{ time }}
        </text>
    </div>
</div>

system.js

import battery from '@system.battery';
import brightness from '@system.brightness';
import device from '@system.device';
import geolocation from '@system.geolocation';
import network from '@system.network';

export default {
    data: {
    // 电量
        batteryLevel: 0.00, // 电池电量,取值范围:0.00 - 1.00
        batteryCharging: false, // 电池是否在充电中

    // 屏幕亮度
        brightness: 0, // 屏幕亮度,取值范围1-255
        brightnessMode: 0, // 屏幕亮度模式, 0为手动, 1为自动

    // 设备
        deviceBrand: '', // 品牌
        deviceManufacturer: '', // 生产商
        deviceModel: '', // 型号
        deviceLanguage: '', // 语言
        deviceRegion: '', // 系统地区
        deviceScreenShape: '', // 屏幕形状 rect:方形屏 circle:圆形屏

    // 地理位置
        locationLongitude: '', // 经度
        locationLatitude: '', // 纬度
        locationAccuracy: '', // 精确度
        time: '', // 时间
        locationType: 'network', // 设备支持的定位类型

    // 网络
        networkType: 'none', // 网络类型,可能值有2g,3g,4g,wifi,none等
        networkMetered: false, // 是否按照流量计费
    },
    onShow() {
        battery.getStatus({
            success: (data) => {
                this.batteryLevel = data.level;
                this.batteryCharging = data.charging;
            },
            fail: (data, code) =>{
                console.info('fail to get battery level code:' + code + ', data: ' + data);
            },
        });

        brightness.getValue({
            success: (data) => {
                this.brightness = data.value;
            },
            fail: (data, code) => {
                console.info('get brightness fail, code: ' + code + ', data: ' + data);
            },
        });

        brightness.getMode({
            success: (data) => {
                this.brightnessMode = data.mode;
            },
            fail: (data, code) => {
                console.info('handling get mode fail, code:' + code + ', data: ' + data);
            },
        });

        device.getInfo({
            success: (data) =>{
                this.deviceBrand = data.brand;
                this.deviceManufacturer = data.manufacturer;
                this.deviceModel = data.model;
                this.deviceLanguage = data.language;
                this.deviceRegion = data.region;
                this.deviceScreenShape = data.screenShape;
            },
            fail: (data, code) => {
                console.info('fail get device info code:' + code + ', data: ' + data);
            },
        });

        geolocation.subscribe({
            success: (data) =>{
                this.locationLongitude = data.longitude.toFixed(2);
                this.locationLatitude = data.latitude.toFixed(2);
                this.locationAccuracy = data.accuracy.toFixed(2);
                this.time = new Date(data.time).toLocaleDateString();
                console.info('success get location. Longitude/Latitude: ' + this.locationLongitude + '/' + this.locationLatitude);
            },
            fail: (data, code) => {
                console.info('fail to get location. code:' + code + ', data:' + data);
            },
        });

        geolocation.getLocationType({
            success: (data) =>{
                this.locationType = data.types.toString();
            },
            fail: (data, code) =>{
                console.info('fail to get location. code:' + code + ', data:' + data);
            },
        });

        network.getType({
            success: (data) => {
                this.networkType = data.type;
                this.networkMetered = data.metered;
            },
            fail: (data, code) =>{
                console.info('fail to get network type code:' + code + ', data:' + data);
            },
        });
    },
    onDestroy() {
        geolocation.unsubscribe();
    }
}

系统信息的获取大多支持直接返回和订阅两种方式,订阅方式会按一定频率返回结果,比如地理信息间隔时间为5秒。使用订阅方式时,要注意在onDestroy()方法中取消订阅。
system.css
本页面主要应用了网格布局grid-template-[columns|rows]。

.container {
    flex-direction: column;
    justify-content: center;
    align-items: center;
}
div text {
    font-size: 24px;
    margin: 4px;
}
.row-top {
    height: 80px;
    justify-content: center;
}
.row-bottom {
    height: 120px;
    justify-content: center;
}
.row-footer {
    height: 40px;
    justify-content: center;
}
.grid-top-left, .grid-top-right, .grig-middle, .grid-bottom-left, .grid-bottom-right {
    display: grid;
    flex-direction: column;
    grid-columns-gap: 4px;
    grid-rows-gap: 4px;
}
.grid-top-left {
    width: 227px;
    grid-template-columns: 70% 30%;
    grid-template-rows: 50% 50%;
    border-right: 1px white;
}
.grid-top-right {
    width: 227px;
    grid-template-columns: 25% 75%;
    grid-template-rows: 50% 50%;
}
.grig-middle {
    height: 150px;
    grid-template-columns: 20% 30% 25% 25%;
    grid-template-rows: 33% 33% 33%;
    border-top: 1px white;
    border-bottom: 1px white;
}
.grid-bottom-left {
    width: 227px;
    grid-template-columns: 55% 45%;
    grid-template-rows: 33% 33% 33%;
    border-right: 1px white;
}
.grid-bottom-right {
    width: 227px;
    grid-template-columns: 48% 52%;
    grid-template-rows: 33% 33% 33%;
    border-right: 1px white;
}
.left {
    justify-content: flex-start;
}
.right {
    justify-content: flex-end;
}

数字华容道

数字华容道是一款经典的休闲益智小游戏,规则简单,将打乱的数字重新排列整齐即可。本例可以设置宫格数、难度级别,宫格越多、数字越混乱,难度越高。

根据当前官方文档,Lite Wearable设备支持Swipe Event,即支持上下左右滑动;Wearable设备不支持Swipe Event,在当前模拟器中左右滑动执行页面切换(页面栈中保存的页面)。TV支持Key Event,支持上下左右方向键(key code 19 ~ 22)。实际上,前一段时间TV、Wearable模拟器均支持键盘的方向键,而最近都失效了。因此当前没有合适的事件触发游戏,只能看看界面咯。有兴趣的同学也可以学习一下张诏添老师的Lite Wearable版数字华容道手机版数字华容道
游戏页面
初始页面:
HarmonyOS TV和Wearable应用开发(JS篇)
游戏中页面:
HarmonyOS TV和Wearable应用开发(JS篇)
游戏成功页面:
HarmonyOS TV和Wearable应用开发(JS篇)
hml布局比较简单,分为三部分:计时区、游戏区、按钮区。游戏界面利用canvas组件绘制,游戏成功后会显示笑脸图片。按钮区的三个按钮功能分别为:返回到Hero列表页面、开始/重新开始游戏、进入游戏设置界面。

<!--huarong.hml-->
<div class="container">
    <text class="seconds">
        用时:{{seconds}}s
    </text>
    <stack class="stack">
        <canvas class="canvas" id="canvas" ref="canvas" onkey="move"></canvas>
        <image src="common/images/smiling.png" class="success" show="{{isSuccess}}" onclick="start"></image>
    </stack>
    <div class="bottom">
        <button type="circle" icon="common/images/hero.png" class="button" onclick="gotoHeroes"></button>
        <button type="circle" icon="common/images/start.png" class="button" onclick="start"></button>
        <button type="circle" icon="common/images/setting.png" class="button" onclick="gotoSetting"></button>
    </div>
</div>
/* huarong.css */
.container {
    flex-direction: column;
    justify-content: center;
    align-items: center;
}
.seconds {
    font-size: 22px;
    text-color: white;
    text-align: center;
    width: 300px;
    height: 40px;
}
.stack {
    width: 305px;
    height: 305px;
    margin-top: 10px;
}
.canvas {
    width: 305px;
    height: 305px;
    background-color: brown;
}
.success {
    left: 50px;
    top: 65px;
    width: 192px;
    height: 192px;
    object-fit: contain;
}
.bottom {
    margin-top: 10px;
    height: 30px;
    justify-content: center;
}
.button {
    margin-right: 10px;
    radius: 15px;
    icon-width: 30px;
    icon-height: 30px;
}

游戏运行逻辑稍复杂一点,当前代码使用方向键移动数字,具体请看JS代码和注释。另外,此处演示了private和public数据的用法,public中的“cols”、“level”是游戏设置页面返回的参数,只有public数据其他页面才能访问:

// huarong.js
import routing from '../../common/routing.js';

const ARROW_UP = 'up';
const ARROW_DOWN = 'down';
const ARROW_LEFT = 'left';
const ARROW_RIGHT = 'right';
const ARROWS = [ARROW_UP, ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT];
const ARROW_KEYS = {
    '19': ARROW_UP,
    '20': ARROW_DOWN,
    '21': ARROW_LEFT,
    '22': ARROW_RIGHT
};
const CANVAS_LENGTH = 305;
const BORDER_WIDTH = 5;
var numbers = [];
var timer;

export default {
    private: {
        seconds: 0,
        isSuccess: false
    },
    public: {
        cols: 4,
        level: 50
    },
    onShow() {
        this.seconds = 0;
        this.isSuccess = false;
        this.clearTimer();
        this.initGrids(this.cols);
        this.drawGrids();
    },
    move(event) {
        if (this.isSuccess || (event.code < 19 || event.code > 22)) {
            return;
        }

        this.swapGrids(ARROW_KEYS[event.code]);
        this.drawGrids();

        if (this.checkResult()) {
            this.clearTimer();
            this.isSuccess = true;
        }
    },
    start() {
        this.seconds = 0;
        this.isSuccess = false;

        this.initGrids(this.cols);
        this.randomGrids(this.level);
        this.drawGrids();

        timer = setInterval(this.timing, 1000);
    },
    gotoSetting() {
        routing.gotoSetting();
    },
    gotoHeroes() {
        routing.gotoHeroes();
    },
    timing() {
        this.seconds++;
    },
    clearTimer() {
        if (timer) {
            clearInterval(timer);
        }
    },
    initGrids(cols) { // 根据宫格数初始化数字二维数组
        numbers = [];
        for (let row = 0; row < cols; row++) {
            numbers[row] = [0];
            for (let col = 0; col < cols; col++) {
                if (row == cols - 1 && col == cols - 1) {
                    numbers[row][col] = 0;
                } else {
                    numbers[row][col] = row * cols + col + 1;
                }
            }
        }
    },
    randomGrids(level) { // 根据难度级别打乱数字,level即移动数字的次数
        this.swapGrids(ARROW_DOWN);
        this.swapGrids(ARROW_RIGHT);

        for (let i = 0; i < level; i++) {
            let randomIndex = Math.floor(Math.random() * 4);
            let direction = ARROWS[randomIndex];
            this.swapGrids(direction);
        }
    },
    drawGrids() { // 绘制数字宫格
        const context = this.$element('canvas').getContext('2d');

        const cols = numbers.length;
        const sideLength = (CANVAS_LENGTH - (cols + 1) * BORDER_WIDTH) / cols
        const heroImage = new Image();
        heroImage.src = 'common/images/hero.png';

        for (let row = 0; row < cols; row++) {
            for (let column = 0; column < cols; column++) {
                context.fillStyle = '#D2691E';
                const leftTopX = column * (BORDER_WIDTH + sideLength) + BORDER_WIDTH;
                const leftTopY = row * (BORDER_WIDTH + sideLength) + BORDER_WIDTH;
                context.fillRect(leftTopX, leftTopY, sideLength, sideLength);

                context.drawImage(heroImage, leftTopX + sideLength - 20, leftTopY + sideLength - 20, 20, 20);

                const gridStr = numbers[row][column].toString();
                if (gridStr != '0') {
                    context.font = '40px HYQiHei-65S';
                    context.fillStyle = '#000000';
                    const offsetX = (sideLength - 22 * gridStr.length) / 2;
                    const offsetY = (sideLength + 40) / 2 - 5;
                    context.fillText(gridStr, leftTopX + offsetX, leftTopY + offsetY);
                }
            }
        }
    },
    swapGrids(direction) { // 与0交换位置
        const cols = numbers.length;

        // 查找0所在的行与列
        let zero_row;
        let zero_col;
        for (let row = 0; row < cols; row++) {
            for (let column = 0; column < cols; column++) {
                if (numbers[row][column] == 0) {
                    zero_row = row;
                    zero_col = column;
                    break;
                }
            }
        }

        let target_row;
        let target_col;
        if (direction == ARROW_LEFT && zero_col != cols - 1) {
            target_row = zero_row;
            target_col = zero_col + 1
        } else if (direction == ARROW_RIGHT && zero_col != 0) {
            target_row = zero_row;
            target_col = zero_col - 1
        } else if (direction == ARROW_UP && zero_row != cols - 1) {
            target_row = zero_row + 1;
            target_col = zero_col;
        } else if (direction == ARROW_DOWN && zero_row != 0) {
            target_row = zero_row - 1;
            target_col = zero_col;
        } else {
            return;
        }

        // 与0交换位置
        numbers[zero_row][zero_col] = numbers[target_row][target_col];
        numbers[target_row][target_col] = 0;
    },
    checkResult() { // 检查数字是否已有序排列
        const cols = numbers.length;
        for (let row = 0; row < cols; row++) {
            for (let column = 0; column < cols; column++) {
                if (row == cols - 1 && column == cols - 1) {
                    return numbers[row][column] == 0;
                }
                if (numbers[row][column] != row * cols + column + 1) {
                    return false;
                }
            }
        }
        return true;
    }
}

游戏设置
HarmonyOS TV和Wearable应用开发(JS篇)
本页面可以设置:

  • 游戏难度 随机移动数字的次数,可选值"30"、"50"、"100"、"200"
  • 宫格列数 可选值"3"、"4"、"5"

设置好后点击返回按钮会重新初始化游戏。

<!--setting.hml-->
<div class="container">
    <text class="text">
        难度
    </text>
    <picker-view selected="{{levelsIndex}}}" range="{{levels}}" type="text" class="picker col-100"
            onchange="levelChange"></picker-view>
    <text class="text">
        列数
    </text>
    <picker-view selected="{{columnsIndex}}" range="{{columns}}" type="text" class="picker col-60"
            onchange="colsChange"></picker-view>
    <button type="circle" icon="common/images/back.png" class="button" onclick="back"></button>
</div>
/* setting.css */
.container {
    flex-direction: row;
    justify-content: center;
    align-items: center;
}
.text {
    width: 60px;
    font-size: 30px;
}
.picker {
    font-size: 30px;
    selected-font-size: 38px;
    focus-font-size: 38px;
}
.col-60 {
    width: 60px;
}
.col-100 {
    width: 100px;
}
.button {
    radius: 15px;
    icon-width: 30px;
    icon-height: 30px;
}
// setting.js
import routing from '../../common/routing.js';
import huarong from '../huarong/huarong.js';

export default {
    data: {
        columns: ["3", "4", "5"],
        levels: ["30", "50", "100", "200"],
        columnsIndex: 1,
        levelsIndex: 1,
    },
    levelChange(event) {
        this.levelsIndex = event.newSelected;
    },
    colsChange(event) {
        this.columnsIndex = event.newSelected;
    },
    back() {
        routing.gotoHuarong(this.levels[this.levelsIndex], this.columns[this.columnsIndex]);
    }
}

VS Java UI

JS UI优点:

  • 类HTML和CSS声明式编程,布局、样式开发更简单
  • 页面导航更简单,只需调用router方法,避免编写UI状态切换的代码
  • 支持纯JavaScript语言开发,更轻量

Java UI优点:

  • 提供了细粒度的UI编程接口,应用开发更加灵活
  • 性能更高

JS UI缺点:

  • 仅支持ES6(ECMAScript 2015)语法,尚不支持ES7(ECMAScript 2016)、ES8(ECMAScript 2017)语法
  • 尚不支持双向数据绑定
  • 尚不支持TypeScript

参考资料

HarmonyOS官方文档 - JS UI框架
HarmonyOS官方文档 - JS API参考

猜你喜欢

转载自blog.51cto.com/7308310/2587060