React Native for Android 实践 -- 实现知乎日报客户端

React Native for Android 的发布,对一个 Android 开发者来说还是有相当的吸引力的。通过前面这篇博客:React Native for Android 入门老虎好不容易入了门了,然后想找一个简单的项目,来练练手。一方面来熟悉一下 RN(React Native, 后面都做此简写),另一方面来验证使用 RN 来实现一个相对完整的项目的可行性。

平时用的最多的客户端之一:知乎日报,这个 APP 相对简单,而且也找到了有人分析的知乎日报 API 分析。就选择它了:实现一个 React Native 版的知乎日报客户端,目标是尽量实现官方客户端一致的效果。

这篇文章主要讲使用 RN 来实现知乎日报客户端的可行性和实现方法。整个项目开源在 GitHub 上:ZhiHuDaily-React-Native,欢迎 Star 和 PR。

基本概念

这里以我的个人理解,快速过一下 React Native 中一些基本概念。如果和官方的理解有些偏差,还请指出。

1. 组件

React Native 主要是通过 Virtual Dom 来实现显示页面或者页面中的模块。可以通过 React.createClass() 来创建自己的 Dom,在 React 中称之为组件(Component)。创建之后,就可以直接像 HTML 标准标签一样使用了。如下:

var MyCustomComponent = React.createClass({  
    ...
});

// 然后就可以这样使用
<MyCustomComponent />  

到底什么是一个组件?我的理解就是页面上的一个逻辑单元。组件可以小到一个按钮,也可以大到整个页面,组件嵌套组合,就成了各种复杂的界面了。

2. 组件生命周期

类似于 Android 中的一个 View,它也有自己的生命周期,有自己的状态。React 组件的数据保存在自己内部的 state 变量中。每个组件都有自己的生命周期,每个生命周期都有对应的回调,这个和 Android 中的 View 非常类似:

  • getInitialState:获得初始化组件状态,只调用一次
  • componentWillMount:组件将要加载,只调用一次
  • componentDidMount:组件加载完成并显示出来了,也就是完成了一次绘制,只调用一次
  • render:绘制组件,可能调用多次。

具体要写自己的页面的话,要从哪里入手呢?我们这里就要来看一下  React.createClass() 是什么的。这个方法可以辅助你创建一个组件,其中传入创建组件的参数,就是自定义组件需要的内容。一个基本的自定义组件写法如下:

var MyCustomComponent = React.createClass({  
    // 这里返回一个对象,设置组件的初始化状态,
    // 后面就可以通过 this.state 来获得这个对象
    getInitialState: function() {
        return {
            key1: data1,
            key2: data2,
            ...
        };
    },
    // 这里一般做一些和界面显示无关的初始化操作
    componentWillMount: function() {
    },
    // 这里一般做加载数据的操作
    componentDidMount: function() {
    },
    // 这是最重要的函数,用来绘制界面,
    // 所有的自定义组件,这个函数是必须提供的
    render: function() {
        return(
            <View>
                ...
            </View>
        );
    },
});

一个自定义组件基本上就是上面那样定义了。只有 render 函数是必须的,其他都是可选的。

3. 组件的数据

绘制界面部分,一般情况下会根据组件的状态 state 来绘制动态页面,例如下面一个最简单的例子:

render: function() {  
    return(
        <Text>{this.state.key1}</Text>
    );
}

这里就是直接把状态中的 key1 的值用 Text 组件直接显示出来。

另外,React 组件中最重要的一个概念就是 state -- 组件的状态。除了前面的使用 getInitialState 方法来设置初始化状态外。在界面逻辑处理或者事件交互的过程中,可以调用 this.setState(...) 方法来修改组件的状态值。

如果在代码中直接修改 state,React 就会把旧状态和新状态做一个 diff,找到变化的部分,然后对应找到和这个变化的值关联的界面部分,请求重新绘制这个部分。例如刚才的例子中,如果调用:

this.setState({key1: 'Hello world!'});  

界面上的 Text 内容马上就会显示出 Hello world!

组件中还有一种数据:属性(Property),这种数据可以通过 this.props 来直接获取,例如非常常见的

<View style={{flex: 1}}>  

这里的 style 就是 View 这个组件的一个属性。

那么属性(props)和状态(state)两种数据有什么区别呢?一般 属性 表示静态的数据,组件创建后,就基本不变的内容,状态 是动态数据。

4. React Native 布局

关于 React Native 的布局,实用的是 FlexBox 实现,类似网页的 CSS 布局方法,具体可以参考官方推荐的 A Complete Guide to Flexbox 和官方文档 Flexbox。关于布局说起原理比较简单,但是要很灵活的写出你想要的样式,还是需要慢慢积累经验。

另外,值得一提的是,React Native 中的样式长度单位,是逻辑单位,概念和 Android 中的 dp 一样。

以上就是 React Native 的基本逻辑,有了这些概念,我们就可以开始写 APP 了。

APP 开发实践

我们要实现的知乎日报的 APP 的主页面是一个文章列表,左边可以滑动出来抽屉,账号信息和显示主题列表。选择主题列表,可以在列表页更新对应主题的文章列表。点击文章列表进入文章详情。还有评论,点赞,登录等功能初期并不计划做。

1. 抽屉的实现

庆幸的是,官方提供了 DrawerLayoutAndroid 组件,这个组件其实就是对 Android 中的 DrawerLayout 的封装。可以参考官方文档,使用过 Native 版本的 DrawerLayout 话,很容易上手这个组件。主要代码如下:

render: function() {  
  ...
  return (
    <DrawerLayoutAndroid
      ref={(drawer) => { this.drawer = drawer; }}
      drawerWidth={Dimensions.get('window').width - DRAWER_WIDTH_LEFT}
      keyboardDismissMode="on-drag"
      drawerPosition={DrawerLayoutAndroid.positions.Left}
      renderNavigationView={this._renderNavigationView}>
      <View style={styles.container}>
         ...
        {content}
      </View>
    </DrawerLayoutAndroid>
  );
}

其中 renderNavigationView 属性,表示抽屉里面显示的内容。本项目的实现,可以参考:ListScreen.js

2. 主页文章列表

文章列表在 Android 可以用 ListView 实现,React Native 也很贴心提供了对应的组件 ListView。实用方法和 Android 原生的也类似,需要提供一个数据源 dataSource 和一个基本的绘制每行界面的函数。借用官方的一个代码片段:

getInitialState: function() {  
  var ds = new ListView.DataSource({rowHasChanged: (r1, r2) => r1 !== r2});
  return {
    dataSource: ds.cloneWithRows(['row 1', 'row 2']),
  };
},

render: function() {  
  return (
    <ListView
      dataSource={this.state.dataSource}
      renderRow={(rowData) => <Text>{rowData}</Text>}
    />
  );
},

这是一个最简的 ListView 使用的例子。其实,React Native 提供的 ListView 比原生还要强大一些,提供了列表的 Section 的支持,列表可以分节,可以显示每节的头部,这个和 iOS 的 UITableView 类似。

因为知乎日报的文章列表是按照日期分 Section 的。具体的使用方法在官方的例子 UIExplorer 中有例子。问项目中可以参考这个文件: ListScreen.js

3. 详情页的实现

知乎日报的文章详情页是使用一个 WebView 显示内容的。遗憾的是,React Native 官方在 Android 上并没有提供 WebView 的支持。好在 React Native 很容易集成原生的组件:Native UI Components。我就按照官方文档,导出一个 React 的 WebView 组件。Java 端的代码如下:

public class ReactWebViewManager extends SimpleViewManager<WebView> {

    public static final String REACT_CLASS = "RCTWebView";

    @UIProp(UIProp.Type.STRING)
    public static final String PROP_URL = "url";

    @UIProp(UIProp.Type.STRING)
    public static final String PROP_HTML = "html";

    @UIProp(UIProp.Type.STRING)
    public static final String PROP_CSS = "css";

    @Override
    public String getName() {
        return REACT_CLASS;
    }

    @Override
    protected WebView createViewInstance(ThemedReactContext reactContext) {
        return new WebView(reactContext);
    }

    @Override
    public void updateView(final WebView webView, CatalystStylesDiffMap props) {
        super.updateView(webView, props);
        if (props.hasKey(PROP_URL)) {
            webView.loadUrl(props.getString(PROP_URL));
        }

        if (props.hasKey(PROP_HTML)) {
            String html = props.getString(PROP_HTML);
            if (props.hasKey(PROP_CSS)) {
                String css = props.getString(PROP_CSS);
                html = "<link rel=\"stylesheet\" type=\"text/css\" href=\"" + css + "\" />" + html;
            }
            webView.loadData(html, "text/html; charset=utf-8", "UTF-8");
        }

    }
}

这里我导出了一个简单的 WebView,并暴露了 url, html, css 三个属性。url 表示网页要显示的网页地址,html 表示要加载的 HTML 字符串, css 表示网页样式链接。还要注册这个 ReactWebViewManagerReactInstanceManager 中。具体代码可以看 MyReactPackage.javaMainActivity.java

在 JS 端,需要做对应的封装:

class ObservableWebView extends React.Component {  
  ...

  render() {
    return <RCTWebView {...this.props} onChange={this._onChange} />;
  }
}

ObservableWebView.propTypes = {  
  url: PropTypes.string,
  html: PropTypes.string,
  css: PropTypes.string,
  onScrollChange: PropTypes.func,
};

var RCTWebView = requireNativeComponent('RCTWebView', ObservableWebView, {  
  nativeOnly: {onChange: true}
});

module.exports = ObservableWebView;  

然后就可以在 React 中使用了,如下:

var MyWebView = require('./WebView');

render: function() {  
    return (
      <View style={styles.container}>
        <MyWebView
          style={styles.content}
          html={this.state.detail.body}
          css={this.state.detail.css[0]}/>
      </View>
    );
}

这样就能直接显示了网页内容,挺出乎我意料的简单。

还有一个细节,官方客户端,随着 WebView 的滑动,头部的 Image 也跟着往上收起来。这里我们就要监听 WebView 的滑动事件,然后来设置头部的 Image 的跟随移动。还好,官方文档也提供了一个可以方便从 Native 往 React 传递事件的方法:Events。跟着文档来,实现了一个 ObservableWebView,继承于原生的 WebView,同时把滑动事件上报给 React:

// ObservableWebView.java
public class ObservableWebView extends WebView {  
    ...
    @Override
    protected void onScrollChanged(final int l, final int t, final int oldl, final int oldt)
    {
        super.onScrollChanged(l, t, oldl, oldt);

        WritableMap event = Arguments.createMap();
        event.putInt("ScrollX", l);
        event.putInt("ScrollY", t);
        ReactContext reactContext = (ReactContext)getContext();
        reactContext.getJSModule(RCTEventEmitter.class).receiveEvent(
                getId(), "topChange", event);
    }
}

这里在 onScrollChanged() 回调中,就是往 React 中报 topChange 事件。事件中包含 ScrollXScrollY 两个值。这里的 topChange 被映射到了 JS 的 onChange 事件。在 React 层就能这样用了: <MyWebView onChange={onChangeCallback}/>,这里的 onChangeCallback 是一个自定义的回调函数。WebView 滑动的时候,就会回调到这个函数中来。为了实用方便,这里还可以做一些封装,把 topChange 封装为我们关心的滑动事件 onScrollChange

class ObservableWebView extends React.Component {  
  constructor() {
    super();
    this._onChange = this._onChange.bind(this);
  }

  _onChange(event: Event) {
    if (!this.props.onScrollChange) {
      return;
    }
    this.props.onScrollChange(event.nativeEvent.ScrollY);
  }

  render() {
    return <RCTWebView {...this.props} onChange={this._onChange} />;
  }
}

详情可以参考:WebView.js

这时,我们就可以在 React 组件中的 onScrollChange 事件回调中实现滑动详情页的头部图片的效果:

onWebViewScroll: function(event) {  
    // 这里移动头部的 Image
},
render: function() {  
    return (
        <View style={styles.container}>
            <MyWebView
              ...
              onScrollChange={this.onWebViewScroll}/>
            <Image
              ref={REF_HEADER}
              source={{uri: this.state.detail.image}}
              style={styles.headerImage} />
            {toolbar}
        </View>
    );
},

这里的写起来也很简单。关键看一下 onWebViewScroll 函数的实现。最简单的实现方法就是,通过 ScrollY 来设置组件的 state,来让 React 自动触发重绘。因为事件上报非常频繁,就会触发大量的重绘,会带来严重的性能问题。

React Native 提供了 Direct Manipulation,也就是直接操作组件,这种方式不会触发重绘,效率会高很多。

onWebViewScroll: function(event) {  
      // 像素转为 React 中的大小单元
    var scrollY = -event / PIXELRATIO;
    var nativeProps = precomputeStyle({transform: [{translateY: scrollY}]});
    // 直接操作组件的属性
    this.refs[REF_HEADER].setNativeProps(nativeProps);
},

到这里,实现这个 React Native 版的知乎日报客户端所涉及的技术点,基本都讲完了。还有很多细节请参考源码:ZhiHuDaily-React-Native,欢迎一起交流,和发 pull request 来一起完善这个项目。

总结

这篇文章几百字就写完了,看起来实现这个客户端并不复杂。其实,这里有远超过我想象的坑,后面我应该还会写一篇文章,来总结这个项目中遇到的坑。总体来说,React Native for Android 作为初期的版本,实现一个简单 APP 已经可行。但是它并不完善,如果想用在实际项目中,还需要慎重考量。

最后,大家可以关注这个项目:ZhiHuDaily-React-Native。希望能对开始关注 React Native 的同学有些帮助。

Read more

Android 上的低功耗蓝牙实践

这是我在 Droidcon Beijing 2016 和 GDG Devfest 2016 上做的分享,以下是正文: Slide 01 我今天分享的主题是 Android 上低功耗蓝牙的实践。这个主题比较小众。我在过去的一年多的时间里,主要是在做低功耗蓝牙相关的开发。接触过程中发现,BLE 的开发和通常的 Android APP 的开发有点不一样,这里需要访问硬件资源,而且涉及到一些协议相关的内容,而且这方面的资料也比较少。今天我从 Android 开发者的角度,来分享一下低功耗蓝牙开发实践。 Slide 02 今天分享的内容,主要包含如下几个部分:首先对蓝牙和低功耗蓝牙做一个简单的介绍;然后介绍 Android 上对低功耗蓝牙的支持;再介绍一下在 Android 平台上可以开发哪些低功耗蓝牙应用;然后是,开发过程中,可以帮助我们调试的工具;最后,总结一下所谓的 “最佳实践”,低功耗蓝牙开发的一些小经验。 Slide

By Race604

React Native 触摸事件处理详解

触控是移动设备的核心功能,也移动应用交互的基础,Android 和 iOS 各自都有完善的触摸事件处理机制。React Native(以下简称 RN)提供了一套统一的处理方式,能够方便的处理界面中组件的触摸事件、用户手势等。本文尝试介绍 RN 中触摸事件处理。 1. RN 基本触摸组件 RN 的组件除了 Text,其他组件默认是不支持点击事件,也不能响应基本触摸事件,所以 RN 中提供了几个直接处理响应事件的组件,基本上能够满大部分的点击处理需求TouchableHighlight, TouchableNativeFeedback, TouchableOpacity 和 TouchableWithoutFeedback。因为这几个组件的功能和使用方法基本类似,只是 Touch 的反馈效果不一样,所以一般我们用 Touchable** 代替。Touchable** 有如下几个回调方法: * onPressIn:点击开始; * onPressOut:点击结束或者离开; * onPress:单击事件回调; * onLongPress:长按事件回调。 它们的基本使用方法如下,

By Race604

React Native 中 ScrollView 性能探究

1 基本使用 ScrollView 是 React Native(后面简称:RN) 中最常见的组件之一。理解 ScrollView 的原理,有利于写出高性能的 RN 应用。 ScrollView 的基本使用也非常简单,如下: <ScrollView> <Child1 /> <Child2 /> ... </ScrollView> 它和 View 组件一样,可以包含一个或者多个子组件。对子组件的布局可以是垂直或者水平的,通过属性 horizontal=true/false 来控制。甚至还默认支持“下拉”刷新操作。另外还有一个特别赞的特性,超出屏幕的 View 会自动被移除,从而节省资源和提高绘制效率。我们来看如下一个例子: class

By Race604

30 天入门 Android 开发, Google 与你一起圆梦

经常会有朋友让我推荐 Android 开发入门的教程或者视频,我一直是推荐看官方的教程。大部分人或者觉得比较迷茫,或者觉得坚持不下去。这次推荐这个《30 天入门 Android 开发》是 Google 亲自发起的免费教学,以学习小组方式,大家可以一起学习和交流。一个好的开始,是成功的一半。让 Google 工程师带领你一起进入多彩的 Android 开发大门。点击这里 报名。 Android 设备已经随处可见,你想尝试一下在 Android 设备上的开发和创新吗?快来跟随 Google 的步伐,一起学习 Android 入门课吧! Google Study Jams 活动介绍 Study Jams 是一个学习 Google 在线课程的活动。该活动由学员自主发起课程学习小组,带领小组成员入门 Android 开发,最终将

By Race604