基本概念
這裏以我的個人理解,快速過一下 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 表示網頁樣式鏈接。還要註冊這個 ReactWebViewManager 到 ReactInstanceManager 中。具體代碼可以看 MyReactPackage.java 和 MainActivity.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 事件。事件中包含 ScrollX 和 ScrollY 兩個值。這裏的 topChange 被映射到了 JS 的 onChange 事件。在 React 層就能這樣用了: ,這裏的 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 的同學有些幫助。