title: React Native 學習筆記--進階(五)--性能、升級版本、特定平臺代碼
tags: React Native
categories: React Native
description:
React Native 進階(五)--性能、升級版本、特定平臺代碼
性能
使用React Native替代基于WebView的框架來開發(fā)App的一個強有力的理由,就是為了使App可以達到每秒60幀(足夠流暢),并且能有類似原生App的外觀和手感。但是,還是有一些地方有所欠缺,以及在某些場合React Native還不能夠替你決定如何進行優(yōu)化,因此人工的干預依然是必要的。
關于“幀”你所需要知道的
視頻中逼真的動態(tài)效果其實是一種幻覺,這種幻覺是由一組靜態(tài)的圖片以一個穩(wěn)定的速度快速變化所產生的。我們把這組圖片中的每一張圖片叫做一幀,而每秒鐘顯示的幀數(shù)直接的影響了視頻(或者說用戶界面)的流暢度和真實感。iOS設備提供了每秒60的幀率,這就留給了開發(fā)者和UI系統(tǒng)大約16.67ms來完成生成一張靜態(tài)圖片(幀)所需要的所有工作。如果在這分派的16.67ms之內沒有能夠完成這些工作,就會引發(fā)‘丟幀’的后果,使界面表現(xiàn)的不夠流暢。
調出你應用的開發(fā)菜單,打開Show FPS Monitor. 你會注意到有兩個不同的幀率(JS和UI):
JavaScript 幀率
對大多數(shù)React Native應用來說,業(yè)務邏輯是運行在JavaScript線程上的。這是React應用所在的線程,也是發(fā)生API調用,以及處理觸摸事件等操作的線程。更新數(shù)據(jù)到原生支持的視圖是批量進行的,并且在事件循環(huán)每進行一次的時候被發(fā)送到原生端,這一步通常會在一幀時間結束之前處理完(一切順利的話)。如果JavaScript線程有一幀沒有及時響應,就被認為發(fā)生了一次丟幀。 例如:你在一個復雜應用的根組件上調用了this.setState,從而導致一次開銷很大的子組件樹的重繪,可想而知,這可能會花費200ms也就是整整12幀的丟失。此時,任何由JavaScript控制的動畫都會卡住。只要卡頓超過100ms,用戶就會明顯的感覺到。
這種情況經常發(fā)生在Navigator的切換過程中:當你push一個新的路由時,JavaScript需要繪制新場景所需的所有組件,以發(fā)送正確的命令給原生端去創(chuàng)建視圖。由于切換是由JavaScript線程所控制,因此經常會占用若干幀的時間,引起一些卡頓。有的時候,組件會在componentDidMount函數(shù)中做一些額外的事情,這甚至可能會導致頁面切換過程中多達一秒的卡頓。
另一個例子是觸摸事件的響應:如果你正在JavaScript線程處理一個跨越多個幀的工作,你可能會注意到TouchableOpacity的響應被延遲了。這是因為JavaScript線程太忙了,不能夠處理主線程發(fā)送過來的原始觸摸事件。結果TouchableOpacity就不能及時響應這些事件并命令主線程的頁面去調整透明度了。
主線程 (也即UI線程) 幀率
很多人會注意到,NavigatorIOS的性能要比Navigator好的多。原因就是它的切換動畫是完全在主線程上執(zhí)行的,因此不會被JavaScript線程上的掉幀所影響。
同樣,當JavaScript線程卡住的時候,你仍然可以歡快的上下滾動ScrollView,因為ScrollView運行在主線程之上(盡管滾動事件會被分發(fā)到JS線程,但是接收這些事件對于滾動這個動作來說并不必要)。
性能問題的常見原因
console.log語句
在運行打好了離線包的應用時,控制臺打印語句可能會極大地拖累JavaScript線程。注意有些第三方調試庫也可能包含控制臺打印語句,比如redux-logger,所以在發(fā)布應用前請務必仔細檢查,確保全部移除。
有個babel插件可以幫你移除所有的console.*調用。首先需要使用npm install babel-plugin-transform-remove-console --save來安裝,然后在項目根目錄下編輯(或者是新建)一個名為·.babelrc`的文件,在其中加入:
{
"env": {
"production": {
"plugins": ["transform-remove-console"]
}
}
}
這樣在打包發(fā)布時,所有的控制臺語句就會被自動移除,而在調試時它們仍然會被正常調用。
開發(fā)模式 (dev=true)
JavaScript線程的性能在開發(fā)模式下是很糟糕的。這是不可避免的,因為有許多工作需要在運行的時候去做,譬如使你獲得良好的警告和錯誤信息,又比如驗證屬性類型(propTypes)以及產生各種其他的警告。
緩慢的導航器(Navigator)切換
Navigator的動畫是由JavaScript線程所控制的。想象一下“從右邊推入”這個場景的切換:每一幀中,新的場景從右向左移動,從屏幕右邊緣開始,最終移動到x軸偏移為0的屏幕位置。切換過程中的每一幀,JavaScript線程都需要發(fā)送一個新的x軸偏移量給主線程。如果JavaScript線程卡住了,它就無法處理這項事情,因而這一幀就無法更新,動畫就被卡住了。
長遠的解決方法,其中一部分是要允許基于JavaScript的動畫從主線程分離。同樣是上面的例子,我們可以在切換動畫開始的時候計算出一個列表,其中包含所有的新的場景需要的x軸偏移量,然后一次發(fā)送到主線程以某種優(yōu)化的方式執(zhí)行。由于JavaScript線程已經從更新x軸偏移量給主線程這個職責中解脫了出來,因此JavaScript線程中的掉幀就不是什么大問題了 —— 用戶將基本上不會意識到這個問題,因為用戶的注意力會被流暢的切換動作所吸引。
不幸的是,這個方案還沒有被實現(xiàn)。所以當前的解決方案是,在動畫的進行過程中,利用InteractionManager來選擇性的渲染新場景所需的最小限度的內容。
InteractionManager.runAfterInteractions的參數(shù)中包含一個回調,這個回調會在navigator切換動畫結束的時候被觸發(fā)(每個來自于Animated接口的動畫都會通知InteractionManager)。
你的場景組件看上去應該是這樣的:
class ExpensiveScene extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {renderPlaceholderOnly: true};
}
componentDidMount() {
InteractionManager.runAfterInteractions(() => {
this.setState({renderPlaceholderOnly: false});
});
}
render() {
if (this.state.renderPlaceholderOnly) {
return this._renderPlaceholderView();
}
return (
<View>
<Text>Your full view goes here</Text>
</View>
);
}
_renderPlaceholderView() {
return (
<View>
<Text>Loading...</Text>
</View>
);
}
};
你不必被限制在僅僅是做一些loading指示的渲染,你也可以繪制部分的頁面內容 —— 例如:當你加載Facebook應用的時候,你會看見一個灰色方形的消息流的占位符,是將來用來顯示文字的地方。如果你正在場景中繪制地圖,那么最好在場景切換完成之前,顯示一個灰色的占位頁面或者是一個轉動的動畫,因為切換過程的確會導致主線程的掉幀。
ListView初始化渲染太慢以及列表過長時滾動性能太差
這是一個頻繁出現(xiàn)的問題。因為iOS配備了UITableView,通過重用底層的UIViews實現(xiàn)了非常高性能的體驗。用React Native實現(xiàn)相同效果的工作仍正在進行中,但是在此之前,我們有一些可用的方法來稍加改進性能以滿足我們的需求。
initialListSize
這個屬性定義了在首次渲染中繪制的行數(shù)。如果我們關注于快速的顯示出頁面,可以設置initialListSize為1,然后我們會發(fā)現(xiàn)其他行在接下來的幀中被快速繪制到屏幕上。而每幀所顯示的行數(shù)由pageSize所決定。
pageSize
在初始渲染也就是initialListSize被使用之后,ListView將利用pageSize來決定每一幀所渲染的行數(shù)。默認值為1 —— 但是如果你的頁面很小,而且渲染的開銷不大的話,你會希望這個值更大一些。稍加調整,你會發(fā)現(xiàn)它所起到的作用。
scrollRenderAheadDistance
“在將要進入屏幕某些區(qū)域中先渲染行,距離按像素計算”
如果我們有一個2000個元素的列表,并且立刻全部渲染出來的話,無論是內存還是計算資源都會顯得很匱乏。還很可能導致非常可怕的阻塞。因此scrollRenderAheadDistance允許我們來指定一個超過視野范圍之外所需要渲染的行數(shù)。
removeClippedSubviews
“當這一選項設置為true的時候,超出屏幕的子視圖(同時overflow值為hidden)會從它們原生的父視圖中移除。這個屬性可以在列表很長的時候提高滾動的性能。默認為true。(0.14版本前默認為false)”
這是一個應用在長列表上極其重要的優(yōu)化。Android上,overflow值總是hidden的,所以你不必擔心沒有設置它。而在iOS上,你需要確保在行容器上設置了overflow: hidden。
我的組件渲染太慢,我不需要立即顯示全部
這在初次瀏覽ListView時很常見,適當?shù)氖褂盟谦@得穩(wěn)定性能的關鍵。就像之前所提到的,它可以提供一些手段在不同幀中來分開渲染頁面,稍加改進就可以滿足你的需求。此外要記住的是,ListView也可以橫向滾動。
在重繪一個幾乎沒有什么變化的頁面時,JS幀率嚴重降低
如果你正在使用一個ListView,你必須提供一個rowHasChanged函數(shù),它通過快速的算出某一行是否需要重繪,來減少很多不必要的工作。如果你使用了不可變的數(shù)據(jù)結構,這項工作就只需檢查其引用是否相等。
同樣的,你可以實現(xiàn)shouldComponentUpdate函數(shù)來指明在什么樣的確切條件下,你希望這個組件得到重繪。如果你編寫的是純粹的組件(返回值完全由props和state所決定),你可以利用PureRenderMixin來為你做這個工作。再強調一次,不可變的數(shù)據(jù)結構在提速方面非常有用 —— 當你不得不對一個長列表對象做一個深度的比較,它會使重繪你的整個組件更加快速,而且代碼量更少。
由于在JavaScript線程中同時做很多事情,導致JS線程掉幀
“導航切換極慢”是該問題的常見表現(xiàn)。在其他情形下,這種問題也可能會出現(xiàn)。使用InteractionManager是一個好的方法,但是如果在動畫中,為了用戶體驗的開銷而延遲其他工作并不太能接受,那么你可以考慮一下使用LayoutAnimation。
Animated的接口一般會在JavaScript線程中計算出所需要的每一個關鍵幀,而LayoutAnimation則利用了Core Animation,使動畫不會被JS線程和主線程的掉幀所影響。
注意:LayoutAnimation只工作在“一次性”的動畫上("靜態(tài)"動畫) -- 如果動畫可能會被中途取消,你還是需要使用Animated。
在屏幕上移動視圖(滾動,切換,旋轉)時,UI線程掉幀
當具有透明背景的文本位于一張圖片上時,或者在每幀重繪視圖時需要用到透明合成的任何其他情況下,這種現(xiàn)象尤為明顯。設置shouldRasterizeIOS或者renderToHardwareTextureAndroid屬性可以顯著改善這一現(xiàn)象。 注意不要過度使用該特性,否則你的內存使用量將會飛漲。在使用時,要評估你的性能和內存使用情況。如果你沒有需要移動這個視圖的需求,請關閉這一屬性。
使用動畫改變圖片的尺寸時,UI線程掉幀
在iOS上,每次調整Image組件的寬度或者高度,都需要重新裁剪和縮放原始圖片。這個操作開銷會非常大,尤其是大的圖片。比起直接修改尺寸,更好的方案是使用transform: [{scale}]的樣式屬性來改變尺寸。比如當你點擊一個圖片,要將它放大到全屏的時候,就可以使用這個屬性。
Touchable系列組件不能很好的響應
有些時候,如果我們有一項操作與點擊事件所帶來的透明度改變或者高亮效果發(fā)生在同一幀中,那么有可能在onPress函數(shù)結束之前我們都看不到這些效果。比如在onPress執(zhí)行了一個setState的操作,這個操作需要大量計算工作并且導致了掉幀。對此的一個解決方案是將onPress處理函數(shù)中的操作封裝到requestAnimationFrame中:
handleOnPress() {
// 謹記在使用requestAnimationFrame、setTimeout以及setInterval時
// 要使用TimerMixin(其作用是在組件unmount時,清除所有定時器)
this.requestAnimationFrame(() => {
this.doExpensiveAction();
});
}
分析
你可以利用內置的分析器來同時獲取JavaScript線程和主線程中代碼執(zhí)行情況的詳細信息。
升級
時刻將React Native更新到最新的版本,可以獲得更多API、視圖、開發(fā)者工具以及其他一些好東西(官方開發(fā)任務繁重,人手緊缺,幾乎不會對舊版本提供維護支持,所以即便更新可能帶來一些兼容上的變更,但建議開發(fā)者還是盡一切可能第一時間更新)。由于一個完整的React Native項目是由Android項目、iOS項目和JavaScript項目組成的,且都打包在一個npm包中,所以升級可能會有一些麻煩。以下是目前所需的升級步驟:
更新react-native的node依賴包
打開項目目錄下的package.json文件,然后在dependencies模塊下找到react-native,將當前版本號改到最新(或指定)版本號,如:
{
"name": "reactnativedemo",
"version": "1.0.0",
"description": "",
"main": "index.android.js",
"scripts": {
"start": "node node_modules/react-native/local-cli/cli.js start"
},
"author": "",
"license": "ISC",
"dependencies": {
"react": "^15.4.1",
"react-native": "^0.38.0"
}
}
react-native的npm包的最新版本可以去這里查看,或使用npm info react-native命令查看。
項目的根目錄執(zhí)行:
npm install
安裝最新的React Native版本,成功后可能會出現(xiàn)如下類似警告:
npm WARN react-native@0.38.0 requires a peer of react@15.4.1 but none was installed.
根據(jù)警告執(zhí)行:
npm install –save react@15.4.1
更新最新的React且項目下package.json 的 dependencies下的react版本會被修改為 15.4.1
升級項目模板文件
新版本的npm包通常還會包含一些動態(tài)生成的文件,這些文件是在運行react-native init創(chuàng)建新項目時生成的,比如iOS和Android的項目文件。為了使老項目的項目文件也能得到更新(不重新init),你需要在命令行中運行:
react-native upgrade
這一命令會檢查最新的項目模板,然后進行如下操作:
- 如果是新添加的文件,則直接創(chuàng)建。
- 如果文件和當前版本的文件相同,則跳過。
- 如果文件和當前版本的文件不同,則會提示你一些選項:查看兩者的不同,選擇保留你的版本或是用新的模板覆蓋。你可以按下h鍵來查看所有可以使用的命令。
注意:如果你有修改原生代碼,那么在使用upgrade升級前,先備份,再覆蓋。覆蓋完成后,使用比對工具找出差異,將你之前修改的代碼逐步搬運到新文件中。
手動升級
有時候React Native的項目結構改動較大,此時還需要手動做一些修改,例如從0.13到0.14版本,或是0.28到0.29版本。所以在升級時請先閱讀一下更新日志,以確定是否需要做一些額外的手動修改。
查看版本是否升級成功
執(zhí)行:
react-native -v
通過如上命令來看最新的版本,檢測是否升級成功!
特定平臺代碼
在制作跨平臺的App時,多半會碰到針對不同平臺編寫不同代碼的需求。最直接的方案就是把組件放置到不同的文件夾下:
/common/components/
/android/components/
/ios/components/
另一個選擇是根據(jù)平臺不同在組件的文件命名上加以區(qū)分,如下:
BigButtonIOS.js
BigButtonAndroid.js
但除此以外React Native還提供了另外兩種簡單區(qū)分平臺的方案:
特定平臺擴展名
React Native會檢測某個文件是否具有.ios.或是.android.的擴展名,然后根據(jù)當前運行的平臺加載正確對應的文件。
假設你的項目中有如下兩個文件:
BigButton.ios.js
BigButton.android.js
這樣命名組件后你就可以在其他組件中直接引用,而無需關心當前運行的平臺是哪個。
import BigButton from './components/BigButton';
React Native會根據(jù)運行平臺的不同引入正確對應的組件。
平臺模塊
React Native提供了一個檢測當前運行平臺的模塊。如果組件只有一小部分代碼需要依據(jù)平臺定制,那么這個模塊就可以派上用場。
import { Platform, StyleSheet } from 'react-native';
var styles = StyleSheet.create({
height: (Platform.OS === 'ios') ? 200 : 100,
});
Platform.OS在iOS上會返回ios,而在Android設備或模擬器上則會返回android。
還有個實用的方法是Platform.select(),它可以以Platform.OS為key,從傳入的對象中返回對應平臺的值,見下面的示例:
import { Platform, StyleSheet } from 'react-native';
const styles = StyleSheet.create({
container: {
flex: 1,
...Platform.select({
ios: {
backgroundColor: 'red',
},
android: {
backgroundColor: 'blue',
},
}),
},
});
上面的代碼會根據(jù)平臺的不同返回不同的container樣式——iOS上背景色為紅色,而android為藍色。
這一方法可以接受任何合法類型的參數(shù),因此你也可以直接用它針對不同平臺返回不同的組件,像下面這樣:
const Component = Platform.select({
ios: () => require('ComponentIOS'),
android: () => require('ComponentAndroid'),
})();
<Component />;
檢測Android版本
在Android上,平臺模塊還可以用來檢測當前所運行的Android平臺的版本:
import { Platform } from 'react-native';
if(Platform.Version === 21){
console.log('Running on Lollipop!');
}
React Native學習筆記--進階(一)--嵌入到Android原生應用中、組件的生命周期、顏色、圖片、觸摸事件
React Native學習筆記--進階(二)--動畫
React Native學習筆記--進階(三)--定時器、直接操作(setNativeProps)、調試
React Native學習筆記--進階(四)--導航器
React Native學習筆記--進階(五)--性能、升級、特定平臺代碼