基於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》, 把Hoc的幾種應用場景都講的比較透,而且還有github代碼直接可以拿來用。

這裏結合我們項目中用到Hoc的場景,稍微展開一下。比如大家都知道React不像Vue提供了v-model的語法糖實現雙向數據綁定(MVVM)。如果一定要雙向綁定怎麼辦呢?可以利用Input-Hoc實現:

export default function InputHoc(OriginalComponent) {
 return class ComposedComponent extends Component {
  constructor(props){
   super(props);
   this.state = {
    inputs: {
    }
   }
  }
  getInput(name, params){
   if(!this.state.inputs[name]){
    this.state.inputs[name] = { 
       value: '',
       onChange: value => this.state.inputs[name].value = value
    }
   }

   return {
       value: this.state.inputs[name].value,
       onChange: this.state.inputs[name].onChange
   }
  }

  render() {
   const props = {...this.props, ...{
    getInput: this.getInput.bind(this)
   }};
   return (
    <OriginalComponent  {...props} />
   );
  }
 }
}

需要雙向綁定的時候,對頁面進行Hoc擴展。

@Input
export default class Login extends Component {
   render() {
      return (<TextInput {...this.props.getInput('username')} />)
   }
}

統一路由層

在跨平臺實現的過程中,最讓人感到頭疼就是頁面路由的實現。因爲iOS,Android,H5各自有其導航方式和偏好。難點在於設計一套通用的路由規則,適用於三個平臺。

首先我們考查了react-router。react-router分react-router-dom 和 react-router-native,支持react.js 和 react native。實測下來react-router-native的頁面導航方式看起來跟SPA一樣,只是頁面內容的替換,不支持原生導航的堆棧。這種導航方式的用戶體驗非常糟糕。比如用在App上,通常可以用拖動屏幕的手勢返回到前一屏。在這個手勢轉場動畫中,當前頁面和上一頁面都可以展示出來。如下圖:

手勢轉場動畫形象的揭示了在原生應用中,頁面是以堆棧的方式存放的。在這個前提下,我們選用了react-navigation作爲我們原生底層實現。
 
原生導航的問題解決後,另一個react-router的常見問題開始困擾我們,即H5上頁面保持的問題,比如上圖中:客戶列表是一個支持下拉分頁,加載更多的列表頁。

點擊一行,可以進入客戶詳情頁。這時從客戶詳情返回到客戶列表頁,需要保持客戶列表頁之前的選中行位置。在原生應用中,由於頁面堆棧的存在,這不是一個問題。但是在H5中使用react-router從客戶詳情返回到客戶列表。客戶列表組件是需要重新創建,重新渲染的。
 
最終我們改用react-keeper作爲我們web層的底層實現,解決了上述問題。並基於react-keeper的路由風格,結合原生的特性,打造了跨平臺的統一路由組件,有效解決了上述的幾個問題。統一路由的設計如下圖:

編譯系統

爲了同時支持react-native和web,項目打包分別採用了metro-bundler和webpack對代碼和資源進行編譯。在web項目中,爲了提升頁面加載速度,對生成代碼進行了分chunk,實現按需加載。

webpack目前提供了一個很好用的插件webpack-bundle-analyzer,直接在編譯的時候做代碼分析,並生成分析報告。

可以通過分析bundle中打入了哪些模塊。對應於一些非必須的模塊, 可以用require.ensure 進行動態加載,或者移到common bundle裏面去。這樣一點點的優化主bundle的大小。最後我們成功的控制了首頁加載小於1MB。

其他問題

MobX實踐中遇到的問題

經過各種嘗試,最終我們在項目中採用了MobX 4.3.1版。 在實際使用過程中,發現幾個需要注意的地方:

1) 只有通過render函數體,或者其子組件所渲染的內容,才能響應observer的變化。如果傳遞一個render回調函數給子組件,其內容不會自動響應observer的變化。

比如下面的情況,this.store是observable對象。在this.store.text發生變化時,render方法不會重繪。

const TabBars = ({content}) => {
…
 {this.props.content}
…
}
@inject(‘store’)
@observer
class Page extends Component{
render() {
     return <TabBars content={<this.content/>} />
  }
  content = () => {
    return <Text>{this.store.text}</Text>
  }
}
  1. 如果採用 mobx-react 修飾頁面,最好保證@observer是需要渲染observable對象的組件外的第一層Hoc,這樣可以有效避免內層Hoc不是直接將原始組件在render函數,或者其子組件中渲染出來的問題。比如:

React-native對Base64圖片的支持問題

react-native的Image組件允許使用 base64編碼的圖片作爲uri。 但是實測下來發現在iOS平臺上有時候base64的圖片顯示不出來。具體場景是使用安全鍵盤時,從服務器獲取亂序的字母數字Base64編碼後的圖片顯示在iOS平臺上,如果直接使用 \<Image  source={{uri: base64Image}} />,在多次大小寫鍵盤,數字和字母鍵盤之間切換後,部分鍵盤按鈕圖片會顯示不出來:

但是通過XCode的截屏工具連手機截圖,截取出來的圖片又是正確的。經排查發現是react-native的Image組件繪製的問題,其github的issue list裏面也承認,並推薦升級到0.57版本。但是升級0.57.4之後,並沒有解決這個問題。
 
因此我們只能想辦法繞過base64,既然react-native Image對普通url的圖片顯示沒有問題,只對base64編碼的支持有問題。那麼就需要將base64轉換成普通圖片url方式。我們採用的是修改原生層代碼,直接在原生層將base64轉換成二進制圖片格式,react-native的js代碼通過特定的url去訪問這個圖片。

首先需要攔截React-Native的網絡請求,這就是原生AOP服務所做的事情。以iOS爲例,我們找到react-native iOS源代碼的RCTHTTPRequestHandler.mm.有這麼一段:

- (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,遠景能源負責移動和雲產品相關設計和開發工作。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章