基於React Native的跨三端應用架構實踐
作者|陳子涵
編輯|覃雲
“一次編寫, 到處執行”(Write once, run anywhere ) 是很多前端團隊孜孜以求的目標。實現這個目標,不但能以最快的速度,將應用推廣到各個渠道,而且還能節省大量人力物力。
React Native 的推出,為跨平臺的開發帶來了新的曙光。 雖然 Facebook 官方 blog 的說法 React Native 支援“Learn once, write anywhere.”。
但經過開源社群的不斷努力,React Native 已經可以達到“一次編寫, 到處執行”的目標。可以說超過了 Facebook 的預期。作者在最近的幾個專案中,運用 React Native 技術,成功實現跨越 iOS,Android,Web 三端的前端架構。這裡將使用到的技術和過程中遇到的困難和問題揭示出來,供讀者探討。
技術選型
我們的目標是希望一套程式碼同時支援 iOS,Android App 和微信公眾號內的網頁(同時保留將來支援桌面瀏覽器的能力)。在開始重構之前,我們盤點了目前可用的一些技術:
① SPA:single page web application,就是隻有一張 html 頁面的應用。僅在該 Web 頁面初始化時載入相應的 HTML、JavaScript、CSS。一旦頁面載入完成,SPA 不會因為使用者的操作而進行頁面的重新載入或跳轉,而是利用 JavaScript 動態的變換 HTML(採用的是 div 切換顯示和隱藏),從而實現 UI 與使用者的互動。
② MPA: multipage web application, 相對於 SPA,MPA 有多個 html 頁面。頁面間跳轉重新整理所有資源,公共資源 (js、css 等) 需選擇性重新載入。
本人於 2012 年開始接觸 Cordova & Ionic,應該說 Cordova 在 React-Native 出現之前確實是跨平臺的主流技術。但是現在是 2018 年,Cordova 在效能上肯定達不到我們的要求,首先被 pass 掉。
Vue.js 也是我們團隊的備選前端框架,主要用於桌面瀏覽器展示的專案。缺乏原生移動解決方案,以及實際用下來感覺 template 表現力比不上 JSX。另外我們用到了螞蟻金服優秀的前端控制元件庫 ant design mobile, 暫時不支援 Vue。
2018 年 7 月份我們對 Flutter(0.5.1) 和 React-Native(0.51.0)進行了一次效能比較測試。我們在 Android 上用 Flutter 和 React-Native 分別實現了一個含圖文的新聞客戶端,比較了頁面載入,圖片載入,頁面跳轉等關鍵效能。實測下來 Flutter 在 List 載入,跳轉到詳情頁時都有明顯掉幀。另外程式碼無法移植到 web 上。這些原因導致我們放棄了 Flutter。
最終我們選擇了 React-Native 作為我們專案的實現技術,除了上述的一些優點之外,我們在如下一些方面收益頗多。
專案架構
我們在專案中用到的前端整體架構如下圖:
以下對上圖中一些技術點進行介紹:
應用支援層
作為應用和後臺服務 & 原生 App 之間的橋樑,應用支援層需要處理諸如端到端通訊,資料加密解密,資料快取,資料攔截,原生應用功能訪問等基礎服務。最大限度的遮蔽掉平臺間差異,讓位於其上的層儘量做到平臺無關。
原生模組封裝
React-Native 可以方便的封裝原生應用模組。對於有 UI 的原生模組,既支援在一個新的 ViewController(Activity)中展示, 也支援將其封裝成一個 View,嵌入到 React-Native 的上下文中。 這也是 React-Native 最接地氣的特性,遠超 Cordova。在一些場景下需要等待原生模組中的事件,諸如使用者操作等非同步事件之後才能返回,這時需要用到 Promise 作為原生模組的引數。
比如通過呼叫手機攝像頭,對銀行卡進行掃描,這時會呼叫原生第三發控制元件的 ScanCardViewController 進行掃描,掃描結果通過代理函式回撥。整個呼叫和回撥的流程無法直接在一個函式中完成,這時可以用 React native 的 Promise 實現對 JS 端 Promise 的無縫對接。
@protocol RCTBankCardScannerDelegate <NSObject>
-(void)onScanCardResult:(NSDictionary *) result;
@end
@interface RCTBankCardScanner()<RCTBankCardScannerDelegate>
@property(nonatomic, strong) RCTPromiseResolveBlock resolveBlock;
@property(nonatomic, strong) RCTPromiseRejectBlock rejectBlock;
@end
@implementation RCTBankCardScanner
RCT_EXPORT_MODULE();
RCT_REMAP_METHOD(scan, resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
{
// 非同步呼叫,函式本體不返回,需要保留 resolve,和 reject 函式指標
self.resolveBlock = resolve;
self.rejectBlock = reject;
// 跳轉到掃描銀行卡控制元件的 ViewController
ScanCardViewController * viewController = [ScanCardViewController new];
UIViewController *rootViewController = RCTPresentedViewController();
[rootViewController presentViewController:viewController animated:YES completion:nil];
}
#pragma mark RCTBankCardScannerDelegate
-(void)onScanCardResult:(NSDictionary *) result
{
// 在原生 ViewController 回撥處,再返回 Promise 的處理結果
if(result != nil && [[result objectForKey:@"code"] isEqualToString:@"0"]){
if(self.resolveBlock != nil){
self.resolveBlock(result);
}
}else if(result != nil){
if(self.rejectBlock != nil){
self.rejectBlock([result objectForKey:@"code"], @"failed", nil);
}
}else{
if(self.rejectBlock != nil){
self.rejectBlock(@"-100", @"invaild response", nil);
}
}
}
上述程式碼實現了銀行卡掃描控制元件的封裝。呼叫 scan 函式的時候會新啟動攝像頭,完成身份證掃描識別之後將結果傳回 JavaScript. 在 JavaScript 中,可以通過
import {NativeModules} from 'react-native'
const BankCardScanner = NativeModules. BankCardScanner
const { code, no } = await BankCardScanner.scan()
實現對原生層的非同步呼叫,並等待 ScanCardViewController 完成並回調。
後臺介面封裝
到伺服器的端到端訪問通過繼承 BaseService 類實現.BaseService 負責處理跟服務端互動,加密,解密,錯誤處理等。
import BaseService from '../common/base-service'
import Page from './Page'
export default class DemoService extends BaseService {
constructor(props) {
super(props)
this.page = new Page(this.getDemoList.bind(this))
}
/**
* 獲取示例列表詳情
*/
async getDemoList (params) {
const res = await this.postJson('getDemoList', params)
return res
}
}
Page 類實現了對分頁資料的載入和儲存封裝,使其與頁面解除耦合。通過指定支援分頁的方法,可以實現分頁載入。
PaginationHoc 則封裝了需要暴露給頁面的分頁相關方法,包括獲取設定支援分頁的 Service,獲取分頁物件,載入下一頁資料,設定搜尋引數等。
一個包含分頁的頁面例子如下:
@Pagination
@Loading
export default class DemoPage extends Component {
constructor(props) {
super(props);
this.props.setService(new DemoService(this.props));
}
async componentDidMount() {
await this.props.loadMore();
}
render() {
return (
<View>
<FlatListView
style={styles.list}
data={this.props.getPage().list}
renderItem={this.renderRow.bind(this)}
hasMore={this.props.hasMore()}
onEndReached={this.props.loadMore.bind(this)}
/>
</View>
);
}
}
全域性異常捕獲
在 web 開發中,可以使用 window.onerror = function(){message, source, …} 來捕獲未處理的 JavaScript 錯誤。但是對於一個遍佈非同步呼叫的複雜應用來說,window.onerror 沒太大用。通常需要捕獲的是未處理的非同步呼叫異常,即 unhandled rejection。
在 web 中,unhandled rejection 可以通過收聽'unhandledrejection'事件來處理。
window.addEventListener('unhandledrejection', function(event) {
const error = event.reason
handleErrors(error);
})
增加了全域性'unhandledrejection'事件監聽之後,依然可以通過 try catch 實現對某個異常的自定義處理,這時全域性'unhandledrejection'事件監聽就不會被呼叫到。如:
try{
await this.service.getDemoList();
} catch (error) {
Modal.alert(‘資料獲取異常’)
}
Promise 目前在 WebKit 系的瀏覽器支援的比較好,如果需要在非 Webkit 核心瀏覽器上使用,通常需要新增 polyfill。這裡需要注意的是專案不能採用 promise-polyfill。因為 promise-polyfill 的實現沒有考慮到'unhandledrejection', 並且會覆蓋瀏覽器原生的 Promise 實現。我們選用的是 es6-promise-promise 庫作為 Promise 的 polyfill 方案。
對於 react-native。非同步異常捕獲未見於其官方文件。但 react-native 的 Promise 模組引用的是 Then Promise 。Then Promise 對於'unhandledrejection',提供了處理鉤子函式:
require('promise/lib/rejection-tracking')
.enable({
allRejections: true,
onUnhandled: function(id, error){
...
}
});
需要注意的是 Then Promise 對 onUnhandle 的預設定義是: 2 秒鐘內沒有被處理的 Promise rejection,因此錯誤處理時一定要考慮到這 2 秒鐘的等待時間。
應用狀態層
相信本文讀者應該多少了解通過 Flux、 Redux、VueX 來管理前端應用狀態的意義了。嚴格說來, 前端應用就是一個通過渲染層,將狀態渲染出來,並通過響應事件來修改狀態的單向資料流模型。對於狀態管理庫的選擇和應用場景,我們在前後幾個專案中經歷了多次嘗試。最開始我們使用 Redux,嘗試按照單向資料流的原教旨主義,通過 Redux 管理應用的全部狀態,效果不理想,主要問題有以下幾點:
-
跟後臺的非同步互動所獲得的資料,如果全部通過 Redux Store 管理,寫法太繁瑣。
-
同一個頁面元件在不同場景(路由)下,訪問同一個 Store。資料到底是清空呢,還是不清空呢?這是一個視具體情況而定的問題。
-
需要多次非同步請求才能完成的操作,需要用 Saga 之類的中介軟體處理,比較麻煩。
後面的專案中我們試圖完全不用狀態管理庫,回到依賴 React 元件的 State 來管理狀態,實操下來發現難以為繼,特別是有主頁面和承接頁面的情況下,如果承接頁的互動,會反映到主頁面的情況下,很難通過純粹的頁面內 State 來實現。
經過摸索,我們最後在架構中採用了 MobX 來作為應用全域性狀態管理器。同時相對弱化了 Store 的地位,僅僅在一些需要採用 Store 的地方利用 Store。經驗看來以下場景中利用 Store 是比較好的設計模式:
-
管理會話狀態,處理使用者登入,登出狀態時,通過 Action & Store 隔絕檢視層和後臺服務呼叫,檢視層不需要處理登入後跳轉到具體頁面,會話超時需要調轉到登入頁等具體而繁瑣的邏輯。只需要通過 Action 來呼叫封裝好的方法即可。
-
主頁面跳轉到承接頁,承接頁進行互動之後,需要主頁面 UI 進行更新的場景。比如主頁面是一個待錄入的產品列表,其中有一項“生產廠商”需要跳轉到承接頁面中選擇,選擇完成之後回到主頁面,並把選中的廠商名字顯示在主介面上。可以在承接頁面中通過 Action 修改 Store,主頁面中監聽 Store 的變更實現。
-
不希望頻繁從伺服器獲取的資料,比如產品列表資料,錯誤型別資料字典,也可以存入 Store。
虛擬 Dom 層
以往手機瀏覽器中複雜頁面的效能優化往往要付出巨大的代價。究其原因是因為手機瀏覽器 DOM 渲染的效能遠遠落後於 JavaScript 執行引擎的效能。而且不同層次(layer)的 Dom 結構和屬性變化,會導致瀏覽器的重繪 (redraw) 和重排 (reflow),需要付出高昂的效能代價。這也是為什麼基於 Cordova 的混合應用,受其效能影響,不適合做有複雜使用者互動,且重視使用者體驗的應用的深度原因。
而 React 創造性的用虛擬 Dom 解決的這個問題。虛擬 DOM,以及其高效的 Diff 演算法。這讓我們在大部分情況下直接讓頁面重繪,而不用擔心效能問題,由虛擬 DOM 來確保只對介面上真正變化的部分進行實際的 DOM 操作。
虛擬 Dom 帶來的另一個好處是構建了超越平臺的 Dom 語言(JSX),使得原來瀏覽器界用於描述介面結構的 Dom 語言,能夠以最小代價適用於其他各種原生應用平臺。在這個領域已經湧現出了部分優秀的開源框架。
經過對比,我們選用 react-native-web 作為 react-native 在 Web 上的實現。 react-native-web 是一個通過將 react-native 的元件和 APIs 在 Web 上重新實現,使得 react-native 應用經過少量更改,可以在瀏覽器上執行的開源專案。官方宣稱支援到 react-native 0.55, 但是我們實測下來,相容 react-native 最新版 (截止專案結束時) 0.57.4 沒什麼問題。
公共模組層
選擇了 react,我們就擁有了大量成熟的開源庫,包括 UI 元件和工具類庫。但是前端的技術迭代週期是非常快的,今年流行的庫,明年說不定就 out 了。
架構設計時必須要考慮前端頁面跟具體控制元件解除耦合。我們的做法是設計出一套標準的控制元件 IDL(介面描述語言),作為媒介溝通頁面跟具體元件實現。比如我們用到了某一個開源的 UI 元件,我們會根據實際業務抽象出一份標準介面,對開源元件進行二次封裝之後再呼叫。這樣即使後續需要更換其他元件,也不需要對頁面進行改動。
所有的 UI 元件,不論是我們自己造輪子寫的,還是開源的,都是按照:1. 定義 IDL -> 2. 進行封裝 -> 3. 實現並上傳 cnpm 伺服器 -> 4. 專案 depencency 中引用來自 cnpm 的元件 IDL。 這樣的流程來進行引用。
高階元件層
在函數語言程式設計的中,Hoc(高階元件) 被廣泛的用於元件中公共功能的複用,以及函數語言程式設計的方式實現元件的擴充套件。我覺得講 Hoc 講的比較好的一篇文章是:《React Higher Order Components in depth》(https://medium.com/@franleplant/react-higher-order-components-in-depth-cf9032ee6c3e) , 把 Hoc 的幾種應用場景都講的比較透,而且還有 github 程式碼直接可以拿來用。
這裡結合我們專案中用到 Hoc 的場景,稍微展開一下。比如大家都知道 React 不像 Vue 提供了 v-model 的語法糖實現雙向資料繫結(MVVM)。如果一定要雙向繫結怎麼辦呢?可以利用 Input-Hoc 實現:
- (NSURLSessionDataTask *)sendRequest:(NSURLRequest *)request
withDelegate:(id<RCTURLRequestDelegate>)delegate
{
// Lazy setup
if (!_session && [self isValid]) {
NSOperationQueue *callbackQueue = [NSOperationQueue new];
callbackQueue.maxConcurrentOperationCount = 1;
callbackQueue.underlyingQueue = [[_bridge networking] methodQueue];
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
[configuration setHTTPShouldSetCookies:YES];
可以通過替換掉 defaultSessionConfiguration,來達到對 http 請求進行攔截的目的。當然可以直接修改 react-native 的程式碼,不過我偏向於利用 Objective-C 的 method swizzling:
@implementation NSURLSessionConfiguration (extend)
+(void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
[self swizzleClassMethod:@selector(defaultSessionConfiguration) withMethod:@selector(aopDefaultSessionConfiguration)];
});
}
+(NSURLSessionConfiguration *) aopDefaultSessionConfiguration{
NSURLSessionConfiguration * instance = [self aopDefaultSessionConfiguration];
Class secureKeyboardURLProtocol = NSClassFromString(@"AOPURLProtocol");
if (secureKeyboardURLProtocol){
instance.protocolClasses = @[AOPURLProtocol];
}return instance;
}
@end
然後我們就可以定義自己的 NSURLProtocol 來對特殊 url 的請求進行攔截了。
@implementation AOPProtocol
+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
if (request != nil) {
NSURL* url = [request URL];
if(url.scheme != nil && [url.scheme isEqualToString:@"demo"]){
return YES;
}
}
return NO;
}
- (void)startLoading{
NSURL *url = [self.request URL];
NSString * path = [url.absoluteString stringByReplacingOccurrencesOfString:@"demo://" withString: @""];
NSData * imgData = [SecureImage imageWithPath: path];
NSDictionary * headersDict = [NSDictionary dictionaryWithObjectsAndKeys:[NSString stringWithFormat:@"%ld", [imgData length]], @"Content-Length",@"image/png",@"Content-Type",nil];
NSHTTPURLResponse* response = [[NSHTTPURLResponse alloc] initWithURL:[self.request URL] statusCode:200 HTTPVersion:@"HTTP/1.1" headerFields:headersDict];
[self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageAllowed];
[self.client URLProtocol: self didLoadData:imgData];
[self.client URLProtocolDidFinishLoading: self];
}
}
這樣,在前端通過請求 demo:// 開頭的,按一定規則索引的 url,就可以返回對應的 png 圖片,順利繞過 base64 圖片的問題。
RN 對中文輸入的支援問題
在 react-native 0.57 之前,如果像這樣寫:
<TextInput value={this.state.value} onChange={val => this.setState({value: val})} />
會面臨中文輸入時無法輸入的問題,解決辦法是不做 value 繫結,而是通過 ref 來獲取值。當然這樣 input-hoc 也沒法用了。
好在 react-native0.57 之後,Facebook 修復了這個問題。
WebView 相關問題
雖然在絕大部分的常見,React-Native 的效能都要超過 WebView。但是由於 React-Native 上目前還缺乏可以媲美 highbharts, e-charts 的報表元件,所以需要繪製報表的時候,還是需要通過 WebView 內嵌 html 的方式實現。
在使用 WebView 時,遇到的問題有兩個:
1.viewport: 頁面指定 viewport 為 device-width 的話,會按螢幕寬度來展現頁面內容。 如果希望 webview 內容不按整個螢幕寬度顯示,則需要計算好 viewport 的寬度,並傳入 webview 裡面的 html 中。
2.Android : android 上 webview 不支援 require 方式載入的 html 資原始檔。比如<WebView source={require('../../components/charts/charts.html')} />
在 iOS 上沒問題,但是在 Android 上實際載入不了。解決的辦法是要麼把 html 檔案放進 android 的 assets 目錄,要麼通過網路載入。
如:
<WebView source={Platform.OS === 'android' ? 'file:///android_asset/charts/charts.html' :
require('../../components/charts/charts.html')} />
總 結
本文介紹了我們基於 React-Native 構建跨平臺的前端應用架構中的一些實踐經驗,以及期間踩的一些坑。希望通過開放地描述我們的技術實現,拋磚引玉供大家探討,得到有益的改進意見和建議。
作者簡介:
陳子涵,7 年以上前端 & 移動架構,跨平臺應用架構設計和開發經驗。曾在 SAP Labs,遠景能源負責移動和雲產品相關設計和開發工作。