作者簡介
萬坤,5年安卓開發經驗,16年加入餓了麼,現任職餓了麼資深安卓開發工程師,負責餓了麼物流安卓相關APP線上的高穩定執行。
前言
Flutter在今年6月份釋出第一個Release預覽版以來,開發熱度呈現了井噴式的爆發。Github上Flutter專案的小星星也已經漲到了3.6萬了,同時國內閒魚團隊已經將Flutter用到了業務中並上線執行。可以說Flutter已經有了非常成熟的使用環境,在我們團隊內部大家也是躍躍欲試。這裡我選擇了我們團隊頁面中一個比較輕量級的頁面-設定頁面,完成了 Flutter的開發和上線準備工作,下面主要是分享一下這一次親密接觸的經驗和心得。
混合開發
實際上我們如果想把Flutter引入到現有的業務中去,就必然會涉及到Flutter和Native混合開發的問題,尤其是Flutter的程式碼怎麼引入到我們原有的工程(實際上官方的Demo是一個純Flutter的工程)。我這邊參考閒魚的做法,在Android端實現的主要步驟如下:
-
1.新建一個Android的module工程。將此工程作為Flutter相關業務打包的工程,最終輸出aar供主工程直接依賴;
-
2.將Flutter的jar包直接引入到lib目錄下。flutter.jar位於 [Flutter SDK目錄]/bin/cache/artifacts/engine,Flutter官方只提供了四種CPU架構的SO庫:armeabi-v7a、arm64-v8a、x86和x86-64, 但是目前我們使用的SDK大部分只使用了armeabi架構,這裡需要將arm目錄下面的jar稍作改造,主要是解壓後將armeabi-v7a目錄更名為armeabi後再打包,可以通過以下的指令碼實現:
cp flutter.jar flutter-armeabi-v7a.jar unzip flutter.jar lib/armeabi-v7a/libflutter.so mv lib/armeabi-v7a lib/armeabi zip -d flutter.jar lib/armeabi-v7a/libflutter.so zip flutter.jar lib/armeabi/libflutter.so 複製程式碼
-
3.新建一個FlutterActivity。這個Activity供Native頁面跳轉。同時也承載了和原生通訊以及頁面route的功能,主要程式碼如下:
public class MyFlutterActivity extends FlutterActivity { @Override protected void onCreate(Bundle savedInstanceState) { FlutterMain.startInitialization(this); super.onCreate(savedInstanceState); GeneratedPluginRegistrant.registerWith(this); //flutter和原生通訊的channel實現 CustomChannel.registerSettingsMethodCall(this, getFlutterView()); } //根據pageId跳到到相應的flutter page public static void start(Context context, String page) { Intent intent = new Intent(context, MyFlutterActivity.class); intent.setAction(Intent.ACTION_RUN); intent.putExtra("route", page); context.startActivity(intent); } } 複製程式碼
-
4.新建Flutter工程,這裡推薦把Flutter工程作為Android工程的一個submodule。
-
5.拷貝Flutter工程build產出物。flutter build之後會生成一些位元組碼和資原始檔,在打包時拷貝到assets目錄下供執行時使用。我們可以在Flutter工程開發完成之後通過以下的指令碼輸出產出物到Android工程:
#這裡涉及的目錄可以視自己的工程結構而定 echo "Switch workspace" cd ./flutter_module echo "Clean old build" find . -d -name "build" | xargs rm -rf flutter clean echo "Get packages" flutter packages get echo "Build release AOT" flutter build aot --release --preview-dart-2 --output-dir=build/flutter/output/aot echo "Build release Bundle" flutter build bundle --precompiled --preview-dart-2 --asset-dir=build/flutter/output/flutter_assets echo "Copy flutter product" cp -rf build/flutter/output/aot/isolate_snapshot_data ../flutter-lib/src/main/assets/ cp -rf build/flutter/output/aot/isolate_snapshot_instr ../flutter-lib/src/main/assets/ cp -rf build/flutter/output/aot/vm_snapshot_data ../flutter-lib/src/main/assets/ cp -rf build/flutter/output/aot/vm_snapshot_instr ../flutter-lib/src/main/assets/ cp -rf build/flutter/output/flutter_assets ../flutter-lib/src/main/assets 複製程式碼
這裡也實現了一個小的指令碼,在Flutter程式碼修改後直接接入到工程中執行:
./script/build.sh #上面的flutterbuild指令碼 ./gradlew app:clean app:assembleDebug adb install -r app/build/outputs/apk/app-debug.apk adb shell am start -n me.ele.fluttermodule.sample/.MainActivity 複製程式碼
不過還是建議直接先在Flutter工程中除錯完成後加入到主工程,畢竟Flutter的hot reload還是挺方便的。
Route
混合開發中遇到的另外一個問題就是頁面的跳轉管理問題,尤其是原生和Flutter之間的相互跳轉,涉及到route問題,這裡Flutter也做了很好的支援:
void main() => runApp(new MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return new MaterialApp(
title: 'flutter demo',
theme: new ThemeData(
primarySwatch: Colors.blue,
),
routes: <String, WidgetBuilder>{
Pages.PAGE_HOME: (BuildContext context) => new HomePage(title: 'flutter'),
Pages.PAGE_SETTINGS: (BuildContext context) => new SettingsPage(),
},
home: new HomePage(title: 'flutter'),
);
}
}
複製程式碼
App可以新增一個routes列表,通過
Navigator.pushNamed(context, routeName);
Navigator.pop(context);
進行頁面的跳轉,在Flutter內部進行頁面的跳轉沒有任何問題,但原生與Flutter之間的頁面跳轉其實遇到了這樣的問題:
我們在一個Flutter工程中實現了多個頁面,他們不總是一個入口,但是這裡卻只有一個入口,home的引數怎麼從Native端傳進來呢?
檢視MaterialApp的原始碼,這裡有一個initialRoute的引數, 他是APP中Navigator預設展示的頁面,而且這個引數接受從Native端傳入。
initialRoute: widget.initialRoute ??ui.window.defaultRouteName,
String get defaultRouteName => _defaultRouteName();
String _defaultRouteName() native 'Window_defaultRouteName';
從這段程式碼裡面可以看到如果在flutter中APP沒有設定initialRoute,就會從Native中獲取。這樣我們就可以在Native端傳入不同的初始頁面,在Android端程式碼可以這樣實現:
Intent intent = new Intent(context, FlutterActivity.class);
intent.setAction(Intent.ACTION_RUN);
intent.putExtra("route", page);
context.startActivity(intent);
複製程式碼
IOS中也有同樣的設定initialRoute的部分。
佈局
Flutter中的佈局是基於Widget的,可以說一切皆Widget。系統給我們提供了大量已經實現好的Widget,基本上我們是在這些Widget的基礎上做一些組合完成佈局的。不過這樣的結果也導致了Widget的結構非常扁平,Widget的種類異常繁多,給上手帶來一些難度。在Flutter IO的目錄中,系統幫我們羅列了大概有146個之多的Widget的型別,這裡我簡單的就我這段時間使用比較高頻的一些Widget談一些自己的體會。
StatelessWidget和StatefulWidget
我們的佈局組合大部分需要繼承這兩個Widget。從字面意義來說很容易區分,一個是有狀態的,一個是無狀態的,但實際使用中卻經常容易混淆。可以說除非是一些寫死的icon,基本上所有的頁面節點都是有狀態的,都會涉及到樣式文案等的更新,主要是看這個state維護在哪裡,如果維護在父控制元件,那麼這個相關的子控制元件就是個無狀態的。下面以CupertinoSwitch 為例簡單的對兩種Widget做一個說明,也是我在實際使用過程中踩過的坑。 CupertinoSwitch是系統提供的一個iOS風格的Switch控制元件,定義非常簡單:
class CupertinoSwitch extends StatefulWidget {
const CupertinoSwitch({
Key key,
@required this.value,
@required this.onChanged,
this.activeColor,
}) : super(key: key);
final bool value;
final ValueChanged<bool> onChanged;
final Color activeColor;
@override
_CupertinoSwitchState createState() => new _CupertinoSwitchState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(new FlagProperty('value', value: value, ifTrue: 'on', ifFalse: 'off', showName: true));
properties.add(new ObjectFlagProperty<ValueChanged<bool>>('onChanged', onChanged, ifNull: 'disabled'));
}
}
class _CupertinoSwitchState extends State<CupertinoSwitch> with TickerProviderStateMixin {
@override
Widget build(BuildContext context) {
return new _CupertinoSwitchRenderObjectWidget(
value: widget.value,
activeColor: widget.activeColor ?? CupertinoColors.activeGreen,
onChanged: widget.onChanged,
vsync: this,
);
}
}
複製程式碼
它是一個StatefulWidget,實際上我們看到這個_CupertinoSwitchState是沒有維護任何資訊的,使用的引數都是Widget的,所以說他也可以是一個StatelessWidget。這裡我曾經也犯過一個錯誤,我在CupertinoSwitch基礎上封裝了一個CheckBox,維護了一個checked的state,我在父控制元件中需要更新非同步返回的資料對CheckBox進行重新整理,發現重新整理無效。原來是因為我只能重新整理Widget的checked,而無法更新他的state,導致他的頁面沒有做更新。實際上在開始寫flutter的佈局時經常會帶著Android的開發思維陷入死衚衕,在Android經常我們通常是先完成控制元件的佈局,然後再找到這些控制元件對這些控制元件做重新整理操作。而在Flutter中,資料都是維護在state中,頁面需要從state中取資料重新整理,Widget可以說都是臨時的,所以不要想著find到這個widget再調他的updateState這種邏輯了。
View和ViewGroup
這其實是Android中的概念了,那在Flutter中有對應的東西嗎?對Widget根據child進行分類,大概可以分成這幾類:
-
1、無child。這類Widget對應我們在Android開發中的基礎View,基本上是頁面展示的最基礎的元素了,像Text、Image、Icon、Checkbox、Switch等,使用比較簡單,這裡就不詳細講述了。
-
2、單child。這類Widget對應我們在Android開發中的style,實際上是對Widget的一些樣式的擴充,在Android中我們通常是把樣式作為View的一個引數,Flutter中則是單獨定義了很多Widget去支援這些樣式。這樣也造成了很多巢狀,實際上單child的這些Widget的多層巢狀並不會帶來效能的損失。多child的Widget則儘量減少巢狀。
- Container。這是使用比較廣泛的一個Widget,它可以給child設定寬高、背景、Margin、Padding等。
- Padding。可以使用EdgeInsets提供的兩種設定padding的方式,all和only。
- Center。子控制元件居中顯示,預設子控制元件佈局是儘量大的。
- Align。設定子控制元件的對齊方式。
-
3、多child。這類Widget對應我們在Android開發中的ViewGroup,涉及到頁面的佈局展示。
- Row。水平的LinearLayout。可以通過不同的主軸和垂直軸的對齊方式,以及結合Expand控制元件,實現非常複雜的flex佈局效果。
- Column。垂直的LinearLayout。
- Stack。FrameLayout。最普通的從左上角開始的佈局,子控制元件相互層疊。
- Table。表格。可以實現豐富的表格效果。
- ListView。滾動的列表。
- Card。Material Design風格的CardView。
問題
記憶體洩漏
在iOS端新開一個Flutter頁面銷燬後記憶體不會被回收,導致記憶體會不斷上漲至應用被殺,應該是iOS端的一個bug,Android端沒有出現,後續的Flutter版本應該會修復,當前需要做一些快取的方式減少記憶體消耗。
黑屏
FlutterActivity在初始化FlutterView的時候比較耗時,會導致頁面啟動的時候黑屏,好在flutterView提供了一個addFirstFrameListener介面,看網上的方法是重寫oncreate中的setContentView方法,在首幀繪製完成前後控制一個loading層的顯示,檢視Flutter原始碼也提供了官方的支援, FlutterActivityDelegate會在setContentView之後新增一個launchView,而launchView是否顯示是根據兩個引數決定的:
//是否顯示lanchView
private Boolean showSplashScreenUntilFirstFrame() {
try {
ActivityInfo activityInfo = activity.getPackageManager().getActivityInfo(
activity.getComponentName(),
PackageManager.GET_META_DATA|PackageManager.GET_ACTIVITIES);
Bundle metadata = activityInfo.metaData;
return metadata != null && metadata.getBoolean(SPLASH_SCREEN_META_DATA_KEY);
} catch (NameNotFoundException e) {
return false;
}
}
複製程式碼
//lanchView 樣式
@SuppressWarnings("deprecation")
private Drawable getLaunchScreenDrawableFromActivityTheme() {
TypedValue typedValue = new TypedValue();
if (!activity.getTheme().resolveAttribute(
android.R.attr.windowBackground,
typedValue,
true)) {;
return null;
}
if (typedValue.resourceId == 0) {
return null;
}
try {
return activity.getResources().getDrawable(typedValue.resourceId);
} catch (NotFoundException e) {
Log.e(TAG, "Referenced launch screen windowBackground resource does not exist");
return null;
}
}
複製程式碼
對應的配置是在manifest的activity中新增一個meta-data(注意是Activity的meta-data,而不是Application的):
<activity
android:name="YourFlutterActivity"
android:theme="@style/FdAppTheme">
<meta-data android:name="io.flutter.app.android.SplashScreenUntilFirstFrame"
android:value="true"/>
</activity>
複製程式碼
activity Theme中新增一個背景:
<item name="android:windowBackground">@color/fd_background</item>
複製程式碼
這樣就會在flutterView載入過程中顯示windowBackground,如果想實現更復雜的lanchview,也可以參照FlutterActivityDelegate的實現方式。
卡頓
android端debug開發的時候頁面顯示非常卡頓。我這邊開發的一個簡單的設定頁面,主要是一個ScrollView包裹著一個Column,debug模式下滑動卡頓,開啟Flutter Inspector也是看到GPU和UI雙曲線飄紅。為了驗證Release包的流暢性,我們在profile模式下開啟Flutter Inspector,看到UI曲線一直顯示綠色,fps也基本穩定在60,感觀上也是操作比較流暢,但是GPU曲線一直飄紅,看官方介紹 offscreen layers對GPU的計算有很大影響,因為涉及到頻繁的呼叫savelayer。可以通過 checkerboardOffscreenLayers這個引數判斷頁面有沒有在螢幕外繪製。
new MaterialApp(
checkerboardOffscreenLayers: true,
);
複製程式碼
我們這裡有一個ScrollView,導致不可避免的產生了螢幕外的檢視。由此可見Flutter對於ScrollView的支援並不高效,後續可以替換成listview提高重用性。
使用心得
開發Flutter將近兩週的時間,使用起來感覺比較得心應手,生態可以說非常的健全了,Widget及Widget的自定義擴充基本上能滿足各種複雜頁面的開發。另外Dart語言可能是對Java開發來說最友好的Web語言了,而且AndroidStudio對它做了很好的支援,基本上我們還是可以做到點選自動跳轉以及class一鍵import了。如果是一個新開的專案,用Flutter實現確實能帶來很大的生產力的提高。
規劃
目前對Flutter基本上只是一個大概的瞭解,後續將從以下幾個方面深入理解整個Flutter框架。
- 渲染流程 閱讀Flutter Engine相關程式碼,深入瞭解底層渲染的原理。
- 元件 網路、本地通訊、儲存、route框架、資料監控等基礎模組的封裝實現。
- 效能工具 Flutter提供了大量的效能檢測工具,藉助這些工具可以定位和優化程式中的效能問題。
- 命令解析 Flutter提供了很多命令列實現編譯和打包,可以深入瞭解其中的實現原理。
- 程式碼架構 可以將MVP、MVVM等架構方案引入到flutter中。
閱讀部落格還不過癮?
歡迎大家掃二維碼通過新增群助手,加入交流群,討論和部落格有關的技術問題,還可以和博主有更多互動
部落格轉載、線下活動及合作等問題請郵件至 shadowfly_zyl@hotmail.com 進行溝通