本文做者:閱文集團前端團隊前端
原創聲明:本文爲閱文前端團隊 YFE 成員出品,請尊重原創,轉載請聯繫公衆號 (id: yuewen_YFE) 獲取受權,並註明做者、出處和連接。react
經歷了三個多月的集中開發,閱文集團旗下二次元產品「元氣閱讀」APP 終於在各大應用商店上架了。「元氣閱讀」APP 大部分的功能模塊基於 React Native 開發,整個開發過程前端團隊趟了很多 React Native 的坑,同時也積累了很多實踐心得,與你們一塊兒分享。android
在使用 React Native (如下簡稱RN)以前,和業界大部分團隊同樣, 咱們 APP 的開發模式採用的是客戶端(iOS/Android)內嵌 H5 的 Hybrid 開發模式。一開始,咱們除了採用比較成熟的離線包方案管理靜態資源,在首屏加載體驗上咱們也作了很多優化工做,但發現 H5 線上的體驗和性能數據與原生仍是有很多差距,因此咱們決定引入新方案。ios
RN 和 Weex 已是業界兩個相對成熟的 Hybrid 解決方案,基本能知足咱們的需求:git
最終咱們選擇了 RN 做爲解決方案,主要是考慮了幾個因素:github
在「元氣閱讀」APP 中,使用 RN 開發的應用場景達到了 70% 左右。用戶能看到的頁面中,除了書架、註冊登陸和閱讀引擎,其它模塊幾乎都是使用 RN 完成開發,「元氣閱讀」APP 已經屬於國內大型產品中,超大規模的 RN 應用了。歡迎你們在各應用商店(iOS、Android)搜索「元氣閱讀」下載體驗。redux
▲小說書城react-native
▲漫畫書城數組
▲元氣圈緩存
▲漫畫詳情
▲排行榜
▲分類
對於 RN 的開發,導航的前期規劃十分重要,一般在搭建項目時就須要提早考慮。關於導航組件的選擇,react-navigation 是個不錯的選擇,咱們但願 react-navigation
能在業務場景更加通用。
Native 與 RN 互跳是最多見的需求。有了統一的 URL,只需維護一份 sitemap 和實現一個 open 接口,就能夠很容易的在 Native 與 RN 中互相跳轉。
react-navigation
是使用 routeName + params
的形式跳轉的,因此須要在調用 router.getStateForAction
以前作一點調整:
// 修正 action: 容許 navigate/push/reset 動做傳 url
if (isPushLikeAction(action) || isReplaceAction(action)) {
if (isRouteUrl(action.routeName)) {
// 使用 path-to-regexp 庫來判斷 url 對應的 routeName + params
const route = parseRouteByUrl(action.routeName)
if (route) {
action.routeName = route.name
action.params = route.params
}
}
}
複製代碼
在 Web 開發中,404 頁面是一個很常見的邏輯,參照上面的方式, RN 能夠這樣實現:
// 修正 action: 當 navigate/push/replace 跳轉到未知 routeName 時,調整爲定義的 notFoundRouteName
if (isPushLikeAction(action) || isReplaceAction(action)) {
// 修正 action: 提供 404 能力
if (allRouteNames.indexOf(action.routeName) === -1) {
const oldAction = { ...action }
action.routeName = notFoundRouteName
action.params = { action: oldAction }
}
}
複製代碼
在項目開發過程當中,常常碰到這樣的需求,回到原來頁面以後要刷新原頁面的數據,好比登陸以後、進入詳情頁完成某操做以後回到列表頁等。
「元氣閱讀」項目剛啓動時 react-navigation
仍是 0.x 版本,只能用 onNavigationStateChange + context
才能讓頁面感知 focus/blur
。1.x 版本以後,咱們能夠經過自帶的 addListener 方法來監聽 didFocus 或 didBlur 事件。
「元氣閱讀」是一個以 RN 爲入口的應用,在正常的使用過程當中,須要頻繁的從 RN 切換到 Native 或從 Native 切換到 RN,這樣就會有多個 RN 頁面(根組件),而第二個根組件在初始化的時候就須要定位到指定頁面,因此和 Native 約定,經過 initialRouteUrl
或 initialRouteName + initialRouteParams
來告訴 RN 須要定位到什麼頁面:
const navigator = getActiveNavigator() // 須要全局維護一個 Navigator 的堆棧
let nextState = originGetStateForAction(action, state) // 調用原始的 getStateForAction 獲取新的/初始化的狀態
if (navigator) {
const { initialRouteName, initialRouteParams, goBackOnTop } = navigator.props // 讀取 navigator 的 props
if (isInitAction(action)) {
// 支持經過 initialRouteName & initialRouteParams 初始化到相應頁面
if (initialRouteName) {
const initialActionPayload = { routeName: initialRouteName, params: initialRouteParams }
const initialAction = NavigationActions.navigate(initialActionPayload)
nextState = router.getStateForAction(initialAction, nextState)
if (!isTopNavigator() && nextState.index > 0) {
// 非第一層 RN 實例且有兩個頁面的時候(前面 navigate 到了非一級頁面),保留最後一個頁面
nextState = {
...nextState,
index: 0,
routes: nextState.routes.slice(-1),
}
}
}
} else if (isBackAction(action)) {
// 在第一層頁面,而且不是是第一個 Navigator,則調用 goBackOnTop 關閉 RN
if (isTopScren(state) && !isTopNavigator( ) && typeof goBackOnTop === 'function') {
goBackOnTop()
if (nextState === state) {
// 防止 Android 的物理返回鍵致使退出 App
nextState = { ...nextState }
}
}
}
}
return nextState
複製代碼
組件 react-navigation
在 2.x 版本新增了狀態本地存儲功能,在 reload 以後能夠直接定位到以前的頁面,可是須要注意兩個點:
rootNavigator
須要有個標記區分,建議以索引區分componentDidCatch
裏面清除本地存儲在「元氣閱讀」裏,咱們常常須要緩存用戶的信息、瀏覽過的書詳情信息以及用戶收到的消息等等,這樣用戶在離線訪問「元氣閱讀」時就能避免白屏或異常的狀況,並且還能夠實現「秒開」。
舉個例子,當用戶第一次打開書籍詳情頁的時候,把書書籍詳情的信息緩存下來;第二次再打開的時候,就能夠達到秒開的效果。秒開效果能夠看下圖:
▲跳轉詳情頁
咱們選擇 redux 和 redux-persist 搭配一塊兒使用,來實現數據共享以及數據持久化緩存。
選擇用 redux 主要是實現數據共享的功能。經過 redux 單項數據流的特色,每一步操做都有跡可循,比較容易排查問題。
在寫 redux 的時候,可能你們以爲會須要寫不少樣板代碼。在這裏推薦一下 redux-actions 這個庫,可以幫助咱們減小一些代碼量。下面簡單的舉一下例子:
// 常見的寫法
export default (state = {}, action) => {
switch (action.type) {
case INCREASE:
return {...state, total: state.total + 1}
case DECREASE:
return {...state, total: state.total - 1}
default:
return state
}
}
// 經過 handleActions 方法
import { handleActions } from 'redux-actions'
export default handleActions({
[INCREASE]: state => {
...state,
total: state.total + 1
},
[DECREASE]: state => {
...state,
total: state.total - 1
}
}, initialState = {})
複製代碼
redux-persist
會訂閱 store,一旦 store 發生變化,就會觸發存儲操做。這樣當咱們操做 store 的時候,數據也就會更新到本地了。
在開發項目的時候可能會發現,咱們在 store 中共享的數據有一些多是不須要被緩存到本地的。好比說搜索結果頁,由於每次搜索的關鍵字不同,結果也是不同的,這樣的數據被緩存到本地就沒有意義。那咱們怎麼來控制一些數據不被緩存到本地呢?
redux-persist
支持配置黑白名單,意思是只持久化白名單中的數據或者不持久化黑名單中的數據。這樣就能夠根據需求來配置黑白名單,從而決定哪些數據須要被緩存到本地,哪些數據不須要被緩存。例如:
import { createStore, applyMiddleware, combineReducers } from 'redux'
import { persistReducer } from 'redux-persist'
import thunkMiddleware from 'redux-thunk'
import storage from 'redux-persist/lib/storage'
const rootPersistConfig = {
storage,
key: '***',
blacklist: ['***'] // 黑名單
}
const enhancer = applyMiddleware(thunkMiddleware)
export const store = createStore(persistReducer(rootPersistConfig, rootReducer), enhancer)
export const persistor = persistStore(store)
複製代碼
A compelling reason for using React Native instead of WebView-based tools is to achieve 60 frames per second and a native look and feel to your apps. Where possible, we would like for React Native to do the right thing and help you to focus on your app instead of performance optimization, but there are areas where we're not quite there yet, and others where React Native (similar to writing native code directly) cannot possibly determine the best way to optimize for you and so manual intervention will be necessary. We try our best to deliver buttery-smooth UI performance by default, but sometimes that just isn't possible.
在 RN 文檔裏看到一段關於性能的解讀,裏面提到:「目前在某些場合 RN 還不可以替你決定如何進行優化(用原生代碼寫也沒法避免),所以人工的干預依然是必要的」,咱們確實在性能優化上花費了很多精力。
運行過 RN 項目的同窗不難發現,咱們第一次進入 RN 頁面時會有一個短暫的白屏,快至幾十毫秒,慢至 1 到 2 秒,白屏時間取決於終端的性能,在低端安卓機子表現最差,並且退出後再進入,仍然會有這個白屏。咱們實施了幾個優化策略:
在客戶端啓動時,就開始對 RN 的 bundle 進行預先加載,咱們發現這樣操做後,白屏操做的時間縮短了很多,特別是安卓設備。但這還不是最完美的,咱們仍然會看到很短暫的白屏。
因爲大部分 APP 必定是先有閃屏,而後才進入首頁。咱們徹底能夠利用這個業務場景,讓 RN 程序躲在閃屏下加載,直到加載完畢,經過 Bridge 通知客戶端把閃屏關閉,這樣就比較巧妙地解決了白屏的問題。
當 JavaScript 線程中同時作不少事情時,很容易就會致使線程掉幀,表現爲頁面卡頓、動畫切換緩慢,咱們可使用「交互優先」的原則去作優化。
例如,頁面轉場這個場景。咱們就能夠把頁面邏輯放在 InteractionManager.runAfterInteractions
的回調中執行,這樣能夠優先保證轉場動畫的執行,而後纔是咱們的頁面邏輯,很好的規避了轉場卡頓的問題。
當咱們呈現一個頁面給用戶時,必定是要在最短期內讓用戶感受到頁面已經展示完畢了,因此咱們在初次展現頁面時,能夠優先顯示固定的佔位信息,配合 loading 或骨架圖佈局不肯定的部分,與此同時咱們纔在背後默默的發起請求(碰到複雜頁面,則可拆分多個異步請求),總之整個過程是先保證頁面可見,再逐步完整。
這是一個組件的子樹。對其中每一個組件來講,SCU 代表了 shouldComponentUpdate
的返回內容,vDOMEq
代表了待渲染的 React 元素與原始元素是否相等,最後,圓圈的顏色代表這個組件是否須要從新渲染。
在 React 中若是隻是一次這樣的組件子樹渲染,並不會有太大的性能問題。但若是對於分頁長列表這種須要成百上千次的渲染場景,會花費很大的開銷在 vDOM
的生成和 Diff
上,而這也直接致使了長列表在 RN 中嚴重的性能問題。那咱們須要作些什麼加以改進呢?先來看看這張組件更新渲染的流程圖:
當一個組件的 state 或者 props 改變時,就進入了生命週期函數 shouldComponentUpdate
,而當 shouldComponentUpdate
返回的是 true ,就會調用 render 方法生成 Virtual Dom
,隨後和舊的 Virtual Dom
進行比對,最終決定是否更新。因此從中咱們明顯地看出 SCU 和 Virtual Dom
的 Diff 是影響 Dom 更新的關鍵所在,爲此咱們分別針對這兩點作了優化:
shouldComponentUpdate
的更新邏輯從上圖也能夠看出若是 shouldComponentUpdate
返回的是 false,那程序就能夠直接跳過生成 Virtual Dom
以及以後的 Diff,這對於一個大列表的場景是至關可觀的優化,例如目前咱們有一個 1000 條數據的列表,在下拉加載 20 條新數據時,若是沒有利用 shouldComponentUpdate
進行控制,會把以前的 1000 條數據也 render 一遍,而在 shouldComponentUpdate
中控制好更新邏輯,就只須要 render 最新的那20條,是否是很大的提高!不過使用 shouldComponentUpdate
要格外當心,你必定要考慮到全部影響更新的邏輯。否則會出現真正須要更新的時候卻也沒能更新。
來看一個具體的例子,場景是 APP 中的分類列表頁,咱們在每個列表項的 render 中打印 log,統計進入 render 的次數。首先來看看 shouldComponentUpdate
不作任何處理的狀況,也就是 shouldComponentUpdate
始終返回的是 true:
shouldComponentUpdate (nextProps, nextState) {
return true
}
複製代碼
再看看咱們在 shouldComponentUpdate
中以圖片的 uri 地址過濾掉沒必要要的渲染項以後的狀況:
shouldComponentUpdate (nextProps, nextState) {
if (nextProps.imgSrc.uri === this.props.imgSrc.uri) {
return false
} else {
return true
}
}
複製代碼
從圖中左邊的控制檯很明顯的看出,過濾後不論加載到哪一頁,都只是渲染最新的20條,減小了大量沒必要要的渲染。再比較一下在相同條件下二者加載一千條數據的時間:
shouldComponentUpdate
的狀況下,越日後會越慢,到 1000 條數據時,再加載新數據所要等待的時間簡直沒法忍受。
若是更新是不可避免的,那隻能想辦法去提升 Virtual Dom
的 Diff 效率。咱們能夠在遍歷數組時給每一項加上惟一的 key 值,這樣在 Diff 階段,能夠準確知道要操做的子組件,提升 Diff 的效率。
合理運用動畫對於 APP 的體驗提高有很大幫助。但咱們在應用動畫時發如今有些場景會出現卡頓、掉幀的現象,本質緣由是因爲 JavaScript 是單線程的,若是線程中在跑一些比較重的任務,就可能會對動畫的性能出現影響。下面介紹幾種辦法,把動畫這件事儘可能交於原生:
針對一次性動畫,建議使用 LayoutAnimation
,它利用了原生的 Core Animation
,使動畫不會被 JS 線程和主線程的掉幀所影響。
setNativeProps
方法可使咱們直接修改基於原生視圖組件的屬性,而不須要使用 setState 來從新渲染整個組件樹。避免了渲染組件結構和同步太多視圖變化所帶來的大量開銷。
在 Animated 動畫設定中,添加 useNativeDriver
字段,並設爲 true,這樣就能夠把動畫的執行交由原生處理。
現在因爲互聯網高速傳播的特效,事物發展的速度愈來愈快,產品快速迭代、試錯的能力就顯得尤其關鍵,做爲開發者,對咱們的挑戰就是如何讓開發完成的功能快速上線,下面來看看咱們是怎麼作的:
咱們選擇 Jenkins 做爲自動化部署方案。經過配置在 Jenkins 中打包腳原本實現自動打包,把 RN 的 bundle 包打到指定的位置,這樣就不用每次打包以前再手動打包了,大大提升了效率。
因爲 Native 端發佈一次新版本的成本比較大,RN 的熱更新能力就成爲了很大的亮點。只須要把最新的 bundle 包發佈到服務器,就可以讓用戶手中的 app 自動下載遠端的 bundle 包,而後無感知的更新,可謂是特別的方便。
咱們通過調研,最終選擇了微軟的 CodePush。它提供給 RN 和 Cordova 開發者直接部署移動應用更新給用戶設備的雲服務,並且還開源了 RN 版本。具體接入的教程能夠查看官方網站,這裏就不一一贅述了。下面主要講幾個須要注意的點:
在 CodePush 上註冊 app 的時候,須要區分 iOS 和 Android,例如 appName-ios
和 appName-android
,在發佈的時候須要在不一樣的平臺分開發布。
在註冊 app 的時候,會返回一套 deployment key
,分別爲 Production 和 Staging 環境(後續也能夠自定義 deployment key
名稱),在集成 CodePush SDK
的時候會用到。Production 對應生產環境的 key,Staging 對應測試環境的 key。這樣就能夠分別更新不一樣環境的包。若是想要查看 app 的 deployment key
表,可使用下面的命令:
code-push deployment ls <appName> -k
複製代碼
RN 端接入 CodePush 很是簡單,只須要在根文件中加入幾行代碼就能夠了。CodePush 傳參的時候能夠根據環境的不一樣作不一樣的配置。代碼大體爲下面這樣:
import React, { Component } from 'react'
import codePush from 'react-native-code-push' // 引入 codePush
const codePushOptions = __DEV__ ? {
updateDialog: true, // 顯示更新彈窗
installMode: codePush.InstallMode.IMMEDIATE // 當即更新(會打斷用戶操做)
} : {
// 下次 app 從後臺切換到前臺時檢查更新,並下載最新的包
checkFrequency: codePush.CheckFrequency.ON_APP_RESUME,
// 下次重啓的時候更替換成最新的包
installMode: codePush.InstallMode.ON_NEXT_RESTART
}
@codePush(codePushOptions)
export default class App extends Component {
render() {
...
}
}
複製代碼
checkFrequency
和 installMode
是可配置的,具體的配置能夠根據需求來決定。
在熱更新的時候須要控制版本號,默認是當前安裝包的版本(三位數版本號),若是須要指定版本號的話,能夠在執行熱更新命令的時候加上 -t
,後面跟須要更新的版本號就好了。
咱們藉助了騰訊 Bugly 平臺進行線上異常的監控。Bugly 平臺能爲開發者提供異常上報與運營統計功能:
例如,下圖是對 Crash 率的統計:
Crash 還能夠根據系統、設備和 APP 版本等維度來細化分析。
還能夠統計最影響用戶的 Top 問題:
在幾個月的開發過程當中,咱們遇到了很多坑,也發現了一些好用或者沒有被注意到的小技巧,下面和你們分享其中的一部分:
在安卓中,加載一張尺寸遠大於容器的圖片,內存會忽然猛漲,在這張圖上下滑動,程序就直接由於內存不足而崩潰瞭如何解決呢?其實辦法也很簡單,只須要設置 Image 組件的 resizeMethod
屬性爲 resize 便可,以下圖:
InteractionManager.runAfterInteractions
時的注意事項咱們知道 InteractionManager.runAfterInteractions
的回調是須要完成動畫後才執行,咱們的程序中發現過這樣一個的 bug,在點擊某個按鈕後,就怎麼也進不到 runAfterInteractions
的回調中。通過排查,原來是咱們執行了一個無限循環的動畫(loading 效果),而且沒有關閉,因此就永遠進不到 runAfterInteractions
的回調了。因此你們在開發中碰到循環動畫要注意處理。
FlatList 有一個叫 getItemLayout
的優化屬性,若是你是個定高的列表項,設置這個屬性能夠大大提升列表渲染的效率。而後咱們遇到的問題是,在高度不確實的時候,也設置了這個屬性,致使最終渲染時實際高度和咱們預設的值不一致,出現了跳動。因此,若是不肯定高度,千萬別設置 getItemLayout
屬性。
這不僅僅是 RN 的問題,各端應該都沒法避免。因此一般在各類技術棧的導航庫中都對此進行了修復,咱們剛開始的預期就是 React Navigation
在內部確定解決了這個問題,但發現實際上並無。因而咱們就對 React Navigation
的跳轉作了一次加強,思路是判斷下個路由的地址和上個路由一致,那就不予處理:
function isInCurrentState (state, nextState, routeName) {
if(nextState && nextState.routeName === routeName && !deepDiffer(state.params, nextState.params)) {
return true
}
if(nextState && nextState.routes) {
return isInCurrentState(state.routes[state.index], nextState.routes[nextState.index], routeName)
}
return false
}
const nextState = originGetStateForAction(action, state)
// 避免重複跳轉
if (nextState && action.type === StackActions.PUSH) {
if(isInCurrentState(state, nextState, action.routeName)) {
return state
}
}
複製代碼
HardWare->Keyboard->Toggle Software Keyboard
進行開關;Debug->Slow Animations
中關閉(快捷鍵是 command+T
)。咱們知道 RN 和原生的通訊是異步的,但若是是一些全局的常量(環境變量、版本信息等),其實能夠以同步的方式在啓動 RN 時直接掛在 NativeModules 上,這樣使用起來就很方便。
defaultSource(iOS Only)
:正常咱們要實現一個默認圖功能,須要先給圖片設置默認圖連接,而後在圖片下載成功的回調裏再改變狀態,替換默認圖。這個屬性就幫你作好了這些,惋惜的是隻支持 iOS。getSize
:當咱們要獲取圖片的寬高,而後再處理圖片相關邏輯,就能夠用這個 API。prefetch
:對圖片強制緩存。queryCache
:這個 API 能夠獲取到圖片是否緩存,若是已緩存,則下發是在硬盤仍是內存。對於要處理一些緩存邏輯仍是頗有用的,不過要注意的是雖然官方沒有標註 Android Only
,咱們只在 Android 獲取成功過,iOS 並沒成功。allowFontScaling(iOS Only)
:這個屬性用來控制是否跟隨系統字體大小。若是你的APP佈局會由於設置字體而失控,能夠考慮開啓,不過此屬性只支持 iOS,安卓須要其它方法解決。selectable
:這個屬性能夠用來開啓文本的複製、粘貼功能。FlatList 提供了一個叫 numColumns
的屬性,你只須要設置一行的列數,即可輕鬆實現一行多列的佈局以下圖:
▲一行三列的佈局
推薦使用 react-native-debugger
,它集成了 Chrome 的 DevTools 以及 react-devtools
,還支持 Redux 的相關調試,能夠說是很強大了。
能夠經過客戶端自帶的軟件進行性能檢測。iOS 推薦 Xcode
自帶的 Profile
;Android 推薦 Android Studio
自帶的 Android Profiler
。
雖然 RN 目前還存在着一些不足,但經過「元氣閱讀」項目實踐,結果證實在人力、性能和效率上,RN 是符合咱們預期的。對於 RN 在業務場景的最佳應用,咱們也總結了幾點:
一個頁面用 Native 仍是 RN 來實現,除了考慮各端團隊人員配比,業務場景也是一個重要的考慮因素。譬如新項目中,做品詳情頁用 Native 或 RN 實現都能達到驗收目標,但考慮到做品詳情頁產品場景已經很成熟,且有很多模塊與核心閱讀頁有較多的交互,對體驗要求也特別高,咱們與終端團隊一致選擇 Native 來實現。
近期 Airbnb、Udacity 團隊紛紛表示棄用 RN,筆者認爲你們大可沒必要爲此憂心忡忡。Airbnb 列舉的條例,其中很多項是可優化,或者結論是有待考究的;另一些也有公司內部自身存在的問題。最近 Facebook 團隊宣佈正在努力打造一次大的升級,其中提到的對線程模型、異步渲染和橋接的優化方向,也讓咱們十分期待,咱們有理由相信 RN 的將來會更好,也但願能經過這篇分享有更多的同窗加入 RN 的你們庭,共同打造更好的 RN 生態。