写给 iOS 程序员的 Weex 教程(3):界面布局和业务逻辑开发

2017/1/15 20:50 下午 posted in  iOS comments

这篇我们将讨论客户端开发的两大块:界面和业务逻辑。这次我会通过做一个简单的应用来讲。
这个应用很简单,展示 github.com 的 trending repo,点击后跳转用网页打开。

we 文件结构

weex 语法的文件都是以 .we 结尾的文件。文件结构分三大部分:templatescriptstyle

//file.we
<template>
</template>

<script>
</script>

<style>
</style>

template 是界面框架,script 是具体业务代码,style 是 CSS 样式部分。

界面:盒模型与 Flexbox

盒模型是指一个 Weex 组件所占空间大小,看下图就能明白:

Weex 的盒模型基于 CSS 盒模型,可以设置 width, height, padding, border 和 margin。
我们平时所设置的宽高代表的是最里面内容区域的大小。需要记住的一点是我们可以单独设置每一个方向的值,这一点可以使我们可以灵活布局。
Weex 只有 Flexbox 一种布局模型(当然你要全部用绝对值布局我也没话说)。
Flexbox 是如何布局呢?我用尝试用大白话翻译一下:

  • Flexbox 将所有组件按一个方向排布(flex-direction),默认垂直方向;
  • 如果组件太多一行/列放不下的话,还可以指定是否折行排列(flex-wrap,当前不支持wrap-reverse);
  • 可以根据规则确定组件相互之间所占空间比例(flex),如果只有一个组件设置了此项,则它占据所有剩余空间;
  • 同时也可以设置当前方向上多余空间如何处理(justify-content);
  • 对于垂直与当前方向(flex-direction)的空间,也能指定对齐方向(align-items);

    以上括号里属性就是 Weex 现在所支持的。

屏幕适配

当前 Weex(0.9.5) 只支持像素这一个单位,并且屏幕宽度被固定成 750 像素。系统会自动根据实际屏幕大小来缩放所有组件。所以在写界面的时候可以不用考虑适配问题。当然如果想实现所有屏幕都显示一样大小的组件也是能做到,只需要自己反向算好缩放就行,可以根据这个公式计算:

var height = {所需尺寸} * 750 / env.deviceWidth

界面实例

了解上面的布局基础知识后,我们就可以来写界面了。界面大概样子如下图:
Simulator_Screen_Shot

主界面是一个列表,重点就在这个 cell 上。布局上我们可以把 cell 分成三个大块:仓库名、描述和底部其他信息。这个三块用默认的竖向排列。这三块里低部这个稍微复杂点,信息较多。这块的特点是两端对齐,自然我们可以把这个分成左右两部分,使用横向布局。大致的布局思路是这样,对照下 cell 的布局源码看就明白了。( icon 我懒得找图,大家意思明白就好)

      <cell style="height: 200px; border-bottom-width: 2px; border-bottom-color: lightgray;">
        <text style="color: #323232; font-size: 30px; margin: 10px">Goodman/goodapp</text>
        <text style="color: #777777; font-size: 24px; lines: 3; text-overflow: ellipsis; margin-left: 10px; margin-right: 10px;">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi eu urna et elit interdum finibus at cursus lectus. Proin nisi ipsum, blandit et malesuada quis, congue a velit. </text>
        <div style="flex-direction: row; margin-top: 10px; justify-content:space-between;">
          <div style="flex-direction: row;">
            <image style="width: 30px; height: 30px; background-color: gray; margin-left: 10px; margin-right: 10px;"></image>
            <text style="font-size: 20px; color: #777777; margin-right: 30px">99999</text>
            <image style="width: 30px; height: 30px; background-color: gray; margin-right: 10px"></image>
            <text style="font-size: 20px; color: #777777;">99999</text>
          </div>
          <div style="flex-direction: row;">
            <text style="font-size: 20px; color: #777777; margin-right: 10px">Good Man</text>
            <image style="width: 30px; height: 30px; background-color: gray; margin-right: 10px;"></image>
          </div>
        </div>
      </cell>

组件拆分和通讯

这个界面很简单,布局代码可以全写在一起。只是为了顺便讲一下如何复用界面组件,我们就把刚才写的 cell 单独写成组件。
写复用组件很简单,和正常的 weex 页面是一样的。其他页面想用某个组件时,只需要在 script 部分引入对应的 we 文件就行:

require('./repo-cell.we');

文件名后缀不要忘了,写上 weex 在编译的时候才能正确识别成组件。组件的文件名就是组件被使用的引用名:

<list class="wrapper">
    <repo-cell></repo-cell>
</list>

list 是 weex 提供的内置列表容器组件,底层实现是 UITableView
组件本身和页面一样,有 script 部分,也有完成的生命周期方法,所以页面上能用的,作为组件都能用。

组件之间通讯

事件监听用 $on, 子组件向父组件发事件用 $dispatch,父组件向子组件发事件用 $broadcast,兄弟组件之间可以通过父组件做一个中转。

// child components
this.$dispatch('naviBar.rightItem.click', {});

// parent components
this.$on('naviBar.rightItem.click', function(e){});

//parent to child
this.$broadcast('event',{});

除了事件外,父组件也可以直接给子组件传递数据。子组件需要现在 data 部分声明变量,然后父组件直接可以在布局部分,绑定数据。注意:data部分用驼峰写法,在布局里需要用横线连接写法:

<!--parent-->
<list class="wrapper">
    <repo-cell repo-info="{{info}}"></repo-cell>
</list>
//child
module.exports = {
    data: {
        repoInfo:null,
    },
};

业务逻辑

本 demo 的业务很简单,获取列表数据和展示,并随着用户滑动来加载更多数据。

加载数据

获取数据需要网络通讯,weex 提供了 stream 模块。stream 是一个异步接口,回调的方式返回数据。为了避免 callback hell, 建议大家用 promise。
Promise 是一个异步调用解决方案。它把一个异步过程疯状到一个 promise 对象里。这个 promise 对象保证未来会有结果返回,不论成功或失败。 promise 对象提供了一个 then 方法,接受两个参数:第一个参数是用来异步成功时的回调,第二个参数是失败或异常情况下的回调。通过 then 方法,我们就可以把相关的业务代码都写在一起,结构清晰:

var promise = asyncCall();
promise.then(function(ret){
  //handle data;
}, function(e){
  //handle error;
});

then 方法返回的是一个新的 promise对象,也就是说我们可以继续衔接调用 then:

promise
.then(func, func)
.then(func, func)

promise 对象还提供了一个 catch 方法, 可以用它来代替 then 方法的第二个参数。建议大家用 catch 方法,因为这样写起来更简洁,方便:

promise
.then(func)
.then(func)
.catch(handleError)

Promise 的具体用法可以看一下其他文档,这里就不赘述。

回到刚才 stream 上,stream本身没提供 promise 的支持,我们需要对其做一个简单的封装:

sendRequest: function(url) {
  var stream = require('@weex-module/stream');
  return new Promise(function(resolve, reject) {
    stream.fetch({
      method: 'GET',
      url: url,
      type: 'json'
    }, function(ret) {
      if (ret.ok) {
        resolve(ret.data);
      }
      else {
        reject(ret.data);
      }
    })
  }, function(){});
}

现在我们就可以开始写数据接口了。 GitHub 没有提供 Trending 的接口,我们就用搜索接口代替一下。
我们要看 Objective-C star 最多的 repos,那请求 url 应该是这样:

var url = 'https://api.github.com/search/repositories?q=language:Objective-C&sort=stars&order=desc&page=' + page;

利用刚才写的 sendRequest 方法,我们的获取列表数据的方法就可以写成这样:

loadNexPage: function(page) {
  page = page || 1;
  var url = 'https://api.github.com/search/repositories?q=language:Objective-C&sort=stars&order=desc&page=' + page; 
  var self = this;
  return this.sendRequest(url)
  .then(function(data) {
    self.repoList = self.repoList.concat(data.items);
    self.canLoadMore = data.items.length === 30; // 30 per page by default
  });
},

现在数据获取逻辑写完了。我们开始把数据展示到界面上。weex支持数据绑定,数据产生变化界面也会改变。绑定的语法是两个大括号:

<text>{{content}}</text>

绑定语法也支持 js 表达式,但只支持单个表达式。想做条件判断的话,可以用条件运算符。

首先,我们先把列表绑定到 cell 上。Weex 提供了 repeat 语法用于支持列表展示:

//template
<list class="wrapper">
 <repo-cell repeat="repo in repoList" repo="{{repo}}"></repo-cell>
</list>

//scripts
 module.exports = {
   data: {
     repoList:[],
     //...
   },
   //....
}

通过 cell 组件的 repo 属性,我们将数据传入到了子组件。这样在子组件里,就能直接绑定数据了:

<template>
  <cell style="border-bottom-width: 2px; border-bottom-color: lightgray;">
    <text style="color: #323232; font-size: 30px; margin: 10px">{{repo.full_name}}</text>
    <text style="color: #777777; font-size: 24px; lines: 3; text-overflow: ellipsis; margin-left: 10px; margin-right: 10px;">{{repo.description}}</text>
    <div style="flex-direction: row; margin-top: 10px; justify-content:space-between; margin-bottom: 20px;">
      <div style="flex-direction: row;">
        <image style="width: 30px; height: 30px; background-color: gray; margin-left: 10px; margin-right: 10px;"></image>
        <text style="font-size: 20px; color: #777777; margin-right: 30px">{{repo.stargazers_count}}</text>
        <image style="width: 30px; height: 30px; background-color: gray; margin-right: 10px"></image>
        <text style="font-size: 20px; color: #777777;">{{repo.forks_count}}</text>
      </div>
      <div style="flex-direction: row;">
        <text style="font-size: 20px; color: #777777; margin-right: 10px">{{repo.owner.login}}</text>
        <image style="width: 30px; height: 30px; background-color: gray; margin-right: 10px;" src="{{repo.owner.avatar_url}}"></image>
      </div>
    </div>
  </cell>
</template>

<script>
  module.exports = {
    data: {
      repo: null,
    },
  };
</script>

到这里基本功能都实现了。现在我们开始实现一下下拉刷新和上拉加载更多。

refresh & load more

weex 自带了这两个两个组件,UI可以自定义。用法很简单, 下面这是一个例子:

    <list class="wrapper">
      <refresh onrefresh="refresh" display="{{showRefresh}}" class="refresh">
        <text if="(showRefresh === 'hide')">下拉刷新</text>
        <loading-indicator class="indicator" if="(showRefresh === 'show')"></loading-indicator>
      </refresh>
      <repo-cell repeat="repo in repoList" repo="{{repo}}"></repo-cell>
      <loading onloading="loadMore" display="{{showLoadMore}}" class="refresh" v-if="canLoadMore">
        <text if="(showLoadMore === 'hide')">上拉加载更多</text>
        <loading-indicator class="indicator" if="(showLoadMore === 'show')"></loading-indicator>
      </loading>
    </list>

上面这段模板里用到了 if 语法。这个语法用来控制是否渲染某个组件。不渲染以为着不会处理任何逻辑,可以加快界面刷新。
模板里的 onrefreshonloading是事件绑定语法,当出现相应事件后会调用后面的方法。后面会详细说明,现在只需知道就好。
这里顺便介绍一下计算属性。计算属性的作用是让我们有机会在数据绑定时支持复杂逻辑。计算属性支持 gettersetter 方法:

computed: {
 fullName: {
   get: function() {
     return this.firstName + ' ' + this.lastName
   },
   set: function(v) {
     var s = v.split(' ')
     this.firstName = s[0]
     this.lastName = s[1]
   }
 }
},

页面跳转

最后,还剩下 cell 的点击事件还未完成。cell 在点击后将打开一个新的页面。新页面是个 web view,将直接加载 repo 的 GitHub 网址。
weex组件提供了好些通用事件,这里我们只需要 click 事件。事件的绑定,只需要写 onXXXX="funcName" ,后面是方法名。也可以直接在这里传递数据并调用:

<repo-cell repeat="repo in repoList" repo="{{repo}}" onclick="showRepo(repo)"></repo-cell>

但是,这里 weex 有一个bug,在子组件上无法用 inline 的形式调用,详情见这个 issue。所以这里只能后接方法名:

<repo-cell repeat="repo in repoList" repo="{{repo}}" onclick="showRepo"></repo-cell>

现在可以在 showRepo 方法里处理事件了。默认会传入事件参数,它有事件相关的属性。

 showRepo: function(e) {
   var utils = require('./utils');
   var repoURL = e.target._vm.repo.html_url; //hack
   var url = utils.absoluteURL('web.js?url=' + repoURL, this.$getConfig().bundleUrl);
   var navigator = require('@weex-module/navigator');
   var params = {
     'url': url,
     'animated': 'true',
   };
   navigator.push(params, function(e) {});
 },

weex 提供了 navigaor 模块用于页面跳转,它需要新页面的绝对地址 URL。但我们写页面地址肯定倾向于写相对地址,这样更灵活。这里我就需要一个方法将相对地址转成绝对地址,然后再加载。转换代码我就不贴了,这里只简单引用。
$getConfig() 是 weex 提供获取基本信息的方法。
大家可能注意到跳转到的地址是 .js结尾而不是 .we,这是因为 weex 会把 we 文件编译成 jsbundle。
跳转后的界面我就不具体说了,只是很简单利用了 weex 提供的 webview 功能。

到此就讲了界面和业务逻辑的大致用法,更细节的问题还是需要大家去看官方文档。
下一篇我们将一下如何构建工作流,方便发布版本

demo下载