與Flutter第一次親密接觸-Android 視角

餓了麼物流技術團隊發表於2018-09-04

作者簡介

萬坤,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中。



閱讀部落格還不過癮?

歡迎大家掃二維碼通過新增群助手,加入交流群,討論和部落格有關的技術問題,還可以和博主有更多互動

與Flutter第一次親密接觸-Android 視角
部落格轉載、線下活動及合作等問題請郵件至 shadowfly_zyl@hotmail.com 進行溝通

相關文章