這些年在開發App上也是遇到過不少坑了……從native到hybrid,從UI到authentication,遇到的坑是接連不斷,踩完一個掉另一個、從老坑爬到新坑一陣子才發現還是老坑好……

現在基本沉澱好了,來總結一下,為以後失憶的自己以及正在辛苦挖掘的新入行的農民們留下點蛛絲馬跡。


為什麼選擇Hybrid App

一開始嘗試的時候也試圖開發native的iOS和Android App,不過很多東西來來回回改,兩邊都要顧及到,一個是開發時候耗時耗力,再就是維護成本也是雙倍的。像我這種懶人……自然更傾向於code once, run everywhere的Hybrid App了!

Hybrid App架構選擇

嘗試過幾種不同的Hybrid App架構,比如Xamarin、Ionic/PhoneGap (Cordova)以及React Native。最後認定的還是React Native——不得不說,真的是好用。

Ionic/PhoneGap

對於一些小型App來說其實Ionic/PhoneGap是一個很好的起點,因為你基本上只需要了解HTML和Javascript就能很好地上手了。

其中Ionic自帶的UI設計甚至會根據手機系統切換(保持和原生App一直的設計,比如說Android用的就是Material Design)。其自帶的豐富多彩的Components也能滿足大部分開發需求——比如按鈕、彈窗、ActionSheet等等。就算這些無法滿足你的App需求,你也還能使用Cordova Native插件來幫助開發——比如很受歡迎的一個就是AdMob,用來在App中插入廣告。

但如果你的項目比較複雜,或者需要展示的東西比較多(大型List),用Ionic/PhoneGap可能會嚴重拖慢App的性能。因為歸根結底Ionic/PhoneGap都是使用內嵌瀏覽器渲染的。雖然有辦法消除原生瀏覽器的300ms延遲,但面對龐大的UI渲染,Ionic/PhoneGap還是很吃力的。曾經我用Ionic做了幾十個卡片,每個卡片上有一些文本信息和一個內嵌的List,整個App滑動起來就已經不是特別順暢了。

Xamarin

Xamarin和Ionic不同,完完全全是基於Native架構的。要說它是個Hybrid開發框架可能不太確切,但它確實是一個非常不錯的跨平台開發框架。和Ionic不一樣,Xamarin是直接和最新的SDK溝通,確保你能用到最新的API,然後把C#寫的代碼編譯成Native App。其實我挺愛Xamarin的。(讀到這裡你們應該能猜到後面會跟一個「但是」了……)

不過(我就不寫「但是」!),讓我棄了Xamarin這個坑的主要原因是它的用戶界面開發。誠然,Xamarin自帶的Xamarin.Forms能夠解決可能90%以上的需求——一次設計,跨平台分發。但同時你也需要注意到剩下10%的use cases會是極其痛苦的,因為一旦你有什麼UI上的需求在自帶庫中無法實現的話,你就需要手擼代碼實現。別理解錯,我說的手擼代碼是你真的需要對所有你想要分發到的平台做一個小插件。比如我當時的需求是在卡片裡內嵌一個可改變順序的List,我就需要在iOS和Android包裡分別寫一個Custom Renderer來實現——他們的代碼還很不一樣,因為底層SDK不同嘛。另外Xamarin的UI設計和Android也比較類似,用的是XAML,也有各種Layout,其實我個人覺得還是比較痛苦的。

最後就是Xamarin提供的插件真心不多——至少在我當時用的時候是吧,還有很多已經屬於比較過時的API了。考慮到需求裡可能會有蠻多沒有插件支持的功能,如果都要自己手擼可就太痛苦了,我還不如切回Native開發呢……所以最終放棄了可愛的Xamarin。

React Native

React Native是我最後接觸到的Hybrid框架了。但是這東西一用就愛上了啊。相較於Ionic和Xamarin,React Native是真的取其精華、去其糟粕了。它擁有Ionic編寫UI的便捷,也擁有Xamarin執行的效率。

它不存在Ionic的性能問題,因為它本來就可以算是個半原生App了。React Native是一個用ES6編寫的框架,在你的App編譯後會將所有代碼打包成一大塊的js文件,然後React Native核心會用Objective-C或Java API將這個js文件渲染成Native View——感謝現代Javascript引擎,讓我們完全感覺不出用React Native開發的App和原生App的區別。

同時你也不需要為UI編寫擔心,因為React Native的UI編寫起來和HTML相差無幾。比如這個就是一個簡單的React Native頁面:

import React, { Component } from 'react';
import { Text, View } from 'react-native';

class App extends Component
{
  render()
  {
    return (
      <View>
        <Text>
          View基本上就是Div。
        </Text>
        <Text>
          Text可以理解為Span吧,但文字必須裝在Text標籤內。
        </Text>
      </View>
    );
  }
}

Expo還是原生React Native

很多剛入門React Native的人會選擇Expo,因為Expo確實更簡單一些,並且有一些Expo官方魔改過的插件。使用Expo,你甚至不需要用數據線將移動設備連接到電腦上即可測試你的App——只需要下載一個Expo App,然後將你的代碼通過WiFi傳輸給手機就可以在手機上測試了。使用Expo,你甚至不需要一台Mac就能編譯iOS版本的App——你可以直接將代碼提交給Expo雲平台進行編譯。

聽起來很方便很酷炫,然而我還是不太推薦剛玩React Native的小夥伴直接使用Expo。因為Expo也是挺多坑的……

首先一個很大的不同就是原生React Native項目裡分別有iOS和Android文件夾,他們存儲的是原生iOS和Android工程文件,如果你需要對其中一個或多個平台做任何修改,只需要打開這些項目,像修改原生工程一樣進行操作即可。有很多很方便的React Native插件也確實需要你對這些文件進行修改。而使用Expo,這倆文件夾會徹底消失,也就是說你沒辦法針對平台做出任何調整,所有代碼都只能以Javascript的形式存在。

其次是在一些小功能上Expo和原生React Native可能會有些許細微的差別。比如我當初用Expo的時候,Android版App上面的Status Bar一直有問題,要麼不能設置成translucent,要麼會自動隱藏,要麼是設置成了translucent卻不佔Header的位置導致Header的文字和Status Bar黏在一起。而這些問題使用原生React Native是沒有的。

最後是插件數量的問題,GitHub/NPM上有很多React Native的插件,但只有很少一部分支持Expo。所以當你覺得你的工程需要很多插件來完善的時候,Expo並不是一個很好的選擇。

希望初入React Native的你不要踏入Expo這個大坑了,還是老老實實用react-native init開始吧。但如果你已經用了Expo——蟹蟹Expo,很貼心的準備了一個功能幫助大家脫離它:npm run eject

版本控制

為什麼版本控制會是個坑呢?其實我也不知道最新版的React Native有沒有解決這個問題,但我當時給我的React Native項目進行Git init的時候莫名其妙就一直在報錯……後來我發現我必須在一個父文件夾內創建repo,然後新建一個子文件夾放React Native項目,這樣就再也沒出現過錯誤了……

UI

因為React Native只是一個代碼級別的框架,在UI層面其實它給與的幫助不是特別多。這時候我們就需要用到第三方插件/庫來美化我們的App,讓它在各個平台看起來都更像原生App——這一點Ionic做得不錯,框架自帶,而React Native除了手擼StyleSheets之外就只能選擇第三方支持了。

我個人試過React Native Elements和NativeBase兩個比較有名的UI框架。這兩個框架都很不錯,前者在GitHub上(目前)擁有13k的star,後者也有10k,都是很流行的項目。但因為Elements自定義樣式還是複雜了點,我更傾向於使用NativeBase。NativeBase在更換基礎樣式上比較容易,但類似ActionSheets或Picker這種頁面級的原件還是比較難自定義的(他們有主題功能,需要自己定義一套主題,但因為我的App需要有夜間配色,切換主題略微繁瑣了所以沒有體驗)。

數據結構

在Javascript驅動的React Native裡做數據結構自然得是JSON了。但這裡又有一個坑了。假設你的數據是一個JSON Array,Array裡每個Object都是一個用戶,每個用戶有用戶名、性別、年齡、喜好、簡介、朋友列表等等信息。你要做一個列表來顯示這個Array就會需要設計一個自定義Component——我們姑且叫他卡片。這個卡片裡可能會有十多個View、十多個Text,並且從父Component獲取用戶信息(props)。

那麼問題來了,當你用ListViewScrollView顯示一百個用戶卡片的時候,執行效率就會明顯降低,甚至刪除一張卡片都會需要幾秒鐘。如果你打開資源查看器甚至會驚人地發現你的RAM正在以肉眼可見速度被吃光……

遇到這個問題,我首先把裝載容器換成了FlatList,但收效甚微——不是說完全沒用吧,但該卡頓還是卡頓。在用戶體驗方面,按下刪除按鈕卡五秒鐘和卡兩秒鐘我覺得差別並沒有太大……

React Native的工作原理是監視一切可變動的數據,當你把整個JSON Array放進State裡就表明這些數據隨時都有可能變動。所以不管是你滑動頁面也好,按下刪除按鈕也好,還是刪掉卡片也好,React Native時時刻刻都在觀察你的數據並在必要的時候(幾乎每時每刻)重新渲染你的頁面。那麼就好理解了,我有100個用戶卡片,時時刻刻重繪100×10個View/Text,能不卡嗎?

這時候我們的救兵來了,它就是Immutable。Immutable可以將JSON Object封裝成immutable數據,標明它們是永遠不會改變的。這時候React Native就不會每時每刻重新渲染你的頁面了,自然效率就提高了不少。但是使用Immutable你將沒有辦法像正常JSON數據一樣使用delete arr[0];let x = arr[0];arr.splice(1, 1);因為這些數據是不可以被改變的。在Immutable結構裡,你想獲得數組內的一個Object或者是獲得一個key的value你必須用let x = arr.get(0);let x = obj.get('key');splice雖然也存在於Immutable中,但返回值和原本的splice不太一樣,它返回的是一個新的、去除掉你想刪除項目的數組。

光有Immutable還不夠,你還是需要FlatList來助陣。FlatList能幫助展示龐大的數據列表。它一次只渲染x個屏幕的數據,若用戶再往下拉,則再渲染更多數據,最大程度地提升了運行效率。但FlatList不支持Immutable,因為FlatList是React Native自帶控件而Immutable是第三方庫。如果你想在FlatList內使用Immutable,要麼你可以用VirtualList(FlatList的父級Component)自行編寫引入Immutable的功能,要麼就使用一個很好用的第三方庫:react-native-immutable-list-view。

Navigation

像很多現代App一樣,我們都想要自己的App有一個底部的標籤切換以及一個頁面切換、後退的Navigation系統。這些東西react-navigation都包含在內。那為什麼他坑呢?

我沒有研究過React Navigation底層是怎麼運作的,但它呈現出來的方式是標籤系統是一個TabNavigator,頁面切換系統叫StackNavigator。你需要在這些Navigator中定義頁面以及Navigation Options。而每個頁面的Header,也就是App頂部的標題和按鈕都是儲存在Navigation Options內的。你可以將這個東西寫在每個頁面自己的js文件內,但很坑的是它是一個static field。所以如果你需要修改Header標題,只能先在Navigation Options裡將標題定義為變量,再在頁面中修改Navigation的變量達到修改標題的目的。

乍一看還好是吧,因為這只是修改文字。當你在Header內有按鈕要call頁面內的函數的時候,一切就十分頭大了……比如我在Header上有個按鈕可以切換頁面內List的複選框,我是這麼做的:

  1. 首先在頁面的componentDidUpdate(prevProps, prevState)函數裡檢查this.props.navigation.state.params.toggleSelectMode是否存在,如果不存在則代表沒有設置標題按鈕的函數:
  2. 設置Navigation的param:this.props.navigation.setParams({ toggleSelectMode: this.toggleSelectMode.bind(this) });,將頁面內的函數設置給Navigation,然後:
  3. 在static navigationOptions裡給按鈕加上onPress函數:if (navigation.state.params.toggleSelectMode) { navigation.state.params.toggleSelectMode(); },注意一定要先檢查是否存在,因為天知道建立Header和componentDidUpdate誰先發生……

是不是很繁瑣?StackNavigator裡切換頁面也會需要用到同樣的hack……比如你想用戶在提交完數據之後回到上一頁並觸發上一頁的一個函數,就得在從上一頁打開本頁的時候就把那個函數以param的形式傳遞過來……

React Navigation還有一個我不知道算不算坑的地方吧,就是老版本內一個StackNavigator包含一個TabNavigator的時候,每個Tab頁面都是可以單獨設置Header標題的,而在新出的2.x版本裡不行了。在2.x版本裡如果你想要每個Tab都有不同的標題,得在TabNavigator本身的Navigation Options裡自己判定當前是什麼頁面,應該給什麼樣的標題。但這樣呢,就將我剛剛提到的Header裡按鈕的問題又提高了幾個幾何級的難度了……所以我一直在用1.0.0-beta.27,也就是最後一個支持每個Tab擁有自己標題的版本。

數據庫

這是個大坑,但取決於你的App Design。如果你在編寫一個Offline First App要求用戶可以使用email/密碼以及其他OAuth登入,當聯網時自動同步兩端的數據,那數據庫的選擇真的會是個大坑……

最早的時候我們選擇的是Firebase,因為這種同步要自己寫一套系統來判斷每個action的發生時間、本地和服務器誰覆蓋誰、哪個設備覆蓋哪個設備真的太繁瑣了,還是直接用已有的BaaS/PaaS服務比較方便。但Firebase遇到的問題是它的filter功能十分局限,比如你只能filter當前object和一級子object的key/value,不能下拉x級子元素filter,也無法count。如果要做複雜一些的filter只能把所有信息都從服務器上下載下來用lodash篩一遍。這樣下載量就太大了,故棄之。

後來發現了Realm。講真,Realm真是一套很好的專為Offline First App提供的數據庫。我們想要的本地/遠程同步和OAuth它都有,filter起來也很方便——雖然還是用到了lodash,但工作量小了很多。可一切完善之後我們發現了問題——OAuth很方便,但email/密碼登入有問題。首先是發送email驗證,這個坑還好,不大,我們先在服務器上寫了個發送email的api,在App內註冊的時候先POST到api,然後在下一步用戶填寫的驗證碼和發送過去的一致的話則通過註冊。

大坑在於重設密碼。因為Realm的用戶名密碼數據都儲存在它自己的系統內,而又不提供任何api修改未登入用戶的密碼,就算我們有用戶的email並且可以發送郵件,也無法在外部更改Realm內用戶的密碼。慌亂之中只好又去看他們的文檔,發現從2.x開始,Realm支持CLI重設密碼,從3.x開始支持發送註冊驗證和重設密碼的email。然而我們的項目構建得比較早,用的還是Realm 1.x。

怎麼辦呢?升級唄,看看是升級到2.x方便還是3.x方便……一看官網,傻眼了,免費版沒了。就連1.x的文檔都找不到了。現在要用Realm每年得花2000美刀。對於一個非盈利App來說確實是有點多了,故而放棄。

在掙扎一番之後,還是瞄準了Firebase,這時候Firebase已經推出了一個新的數據結構Cloud Firestore,宣稱擁有更好更方便的sorting和filtering。再加上Firebase本身就擁有絕佳的Authentication,不光能用email/密碼和Google賬號登入,還支持Twitter、Facebook、GitHub等各種常用的方式,我們決定再給Firebase一次機會xD。

希望這次不會坑了吧_(:з」∠)_


2018年9月15日23點15於溫哥華
撰文共計4小時