1. 程式人生 > >基於React Native的跨三端應用架構實踐

基於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 管理應用的全部狀態,效果不理想,主要問題有以下幾點:

  1. 跟後臺的非同步互動所獲得的資料,如果全部通過 Redux Store 管理,寫法太繁瑣。

  2. 同一個頁面元件在不同場景(路由)下,訪問同一個 Store。資料到底是清空呢,還是不清空呢?這是一個視具體情況而定的問題。

  3. 需要多次非同步請求才能完成的操作,需要用 Saga 之類的中介軟體處理,比較麻煩。

後面的專案中我們試圖完全不用狀態管理庫,回到依賴 React 元件的 State 來管理狀態,實操下來發現難以為繼,特別是有主頁面和承接頁面的情況下,如果承接頁的互動,會反映到主頁面的情況下,很難通過純粹的頁面內 State 來實現。

經過摸索,我們最後在架構中採用了 MobX 來作為應用全域性狀態管理器。同時相對弱化了 Store 的地位,僅僅在一些需要採用 Store 的地方利用 Store。經驗看來以下場景中利用 Store 是比較好的設計模式:

  1. 管理會話狀態,處理使用者登入,登出狀態時,通過 Action & Store 隔絕檢視層和後臺服務呼叫,檢視層不需要處理登入後跳轉到具體頁面,會話超時需要調轉到登入頁等具體而繁瑣的邏輯。只需要通過 Action 來呼叫封裝好的方法即可。

  2. 主頁面跳轉到承接頁,承接頁進行互動之後,需要主頁面 UI 進行更新的場景。比如主頁面是一個待錄入的產品列表,其中有一項“生產廠商”需要跳轉到承接頁面中選擇,選擇完成之後回到主頁面,並把選中的廠商名字顯示在主介面上。可以在承接頁面中通過 Action 修改 Store,主頁面中監聽 Store 的變更實現。

  3. 不希望頻繁從伺服器獲取的資料,比如產品列表資料,錯誤型別資料字典,也可以存入 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,遠景能源負責移動和雲產品相關設計和開發工作。