Flutter 介紹 & 經驗總結

連續三屆村草發表於2019-08-23

前言

Flutter 已經推出2年了,雖然一直在關注,但還是想等生態成熟一點再去踩坑。近期有一個需要使用跨平臺技術的專案,在討論後,我們選擇使用 Flutter。開發完成之後,我這裡總結一些重要的點,供大家參考。
當然,要學習的話最後還是需要讀一遍文件,然後自己 Coding。

環境配置:

參考官方文件

Dart 語言

Flutter 採用 Dart 語言,我使用之後的感受就是: 語法基本等於 Java + Javascript + 另外一些常見的語法,沒太大學習成本,也沒太大亮點,下面列一些值得一提的點。

  • 所有變數都是物件

  • 靜態語言

  • 支援閉包

  • 方法是頂級的

  • 支援反射(Flutter 不支援反射)

  • 沒有可見性修飾符 屬性/類前加_就是 private

  • Stream : 支援 map... 各類操作符,訂閱等

  • 非同步:Dart 的非同步操作也通過 Futrue(同 Javascript 中的 Promise) 的方式實現,也支援 async await 語法糖(自動包裝為Futrue)。這並不是 Dart 特有的特性,網上有大量資料可以參考。

  • 賦值操作符

    • ?:
    • ??
    • ??=
  • 可選方法引數

 void setUser(String name,{id = '0'});
 //呼叫
 setUser('mario',id : '01');
複製程式碼
  • 聯級操作符
   var profit = Profit()
     ..fund = 'fund'
     ..profit = 'profit'
     ..profitValue = 'profitValue';
複製程式碼
  • dynamic 可以指代任何型別,不會進行型別檢查。
var a = 'test';
(a as dynamic).hello();//編譯器不會報錯
複製程式碼

Flutter

Widget 概念

在Flutter中幾乎所有的物件都是一個Widget。與原生開發中“控制元件”不同的是,Flutter中的Widget的概念更廣泛,它不僅可以表示UI元素,也可以表示一些功能性的元件如:用於手勢檢測的 GestureDetector widget、用於APP主題資料傳遞的Theme等等,而原生開發中的控制元件通常只是指UI元素。

我的理解為 Widget 的工作 = HTML + CSS 的工作。而且很多配置樣式的屬性名字和 CSS 中的名字差不多。

Widget 分為 StatelessWidget StatefulWidget 兩種,他們的核心方法都是通過build()方法返回一個 Widget 。

  @protected
  Widget build(BuildContext context);
複製程式碼
  • StatelessWidgetbuild()在 Widget 中。
  • StatefulWidget由於必須建立相應的 State<T extends Widget> ,所以包括build()在內的相關生命週期方法都在State中。
    下面是State的生命週期,由於一個畫面也是一個 Widget 所以也是一個畫面的生命週期。

widget_lifecyle.jpg

Widget 目錄 ( link )

widgets.png

上面是官方提供的所有的 Widget,可以看到基本上所有UI相關的內容都是通過不同型別的 Widget 來實現,通過child/children引數進行巢狀。

不同風格的 Widget

除了基礎 Widget 外,官方提供了 Material(Android) + Cupertino(ioS) 兩種視覺風格的 Widget。
例如你可以在使用一個 Marterial 風格的RaisedButton或是 Cuptino 風格的CupertinoButton,再也不用擔心設計師讓 Android 照著 ioS 做成一樣了。

Layout Widget

還有用來控制佈局的 Layout Widget ,作為容器來使用,看名字都大概知道什麼作用了。

  • Container
  • Padding
  • Center
  • Stack
  • Column
  • Row
  • Expanded
  • ListView

互動模型 Widget

控制點選、滑動等互動的 Widget。
在 Flutter 裡點選事件並不是setOnClickListener的方式 ,而是給 Widget 外層加一層互動 Widget ,如點選可使用GestureDetector。 例如給上面 Splash 畫面中的Image加一個點選事件。

  @override
  Widget build(BuildContext context) {
    return Container(
      height: double.infinity,
      width: double.infinity,
      child: Image.asset('images/logo.png'),
    );
  }
  
  ==>
  
  @override
  Widget build(BuildContext context) {
    return Container(
      height: double.infinity,
      width: double.infinity,
      child: GestureDetector(
        onTap: () {
          //點選事件
        },
        child: Image.asset('images/logo.png'),
      ),
    );
  }
複製程式碼

Sample

所以,一個最基本的 Widget 長什麼樣?這是一個帶有是否 login 檢查的 Splash 畫面。

  • StatelessWidget
class SplashPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    checkLogin();
    return Container(
      height: double.infinity,
      width: double.infinity,
      child: Image.asset('images/logo.png'),
    );
  }

// 使用 async 語法自動包裝為 Futrue,也就是說這個方法是非同步的。
  checkLogin(BuildContext context) async {
    var sp = await SharedPreferences.getInstance();
    var token = sp.getString("X-Auth-Token");
    if (token != null && token != "")
      Navigator.pushNamedAndRemoveUntil(
          context, HomePage.routeName, (_) => false);
    else
      Navigator.pushNamed(context, LoginRegisterPage.routeName);
  }
}

複製程式碼
  • StatefulWidget
class SplashPage extends StatefulWidget {
  //建立相應的 State
  @override
  State createState() => _SplashState();
}

class _SplashState extends State<SplashPage> {
  @override
  void initState() {
    super.initState();
    checkLogin(context);
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      height: double.infinity,
      width: double.infinity,
      child: Image.asset('images/logo.png'),
    );
  }

// 使用 async 語法自動包裝為 Futrue,也就是說這個方法是非同步的。
  checkLogin(BuildContext context) async {
    var sp = await SharedPreferences.getInstance();
    var token = sp.getString("X-Auth-Token");
    if (token != null && token != "")
      Navigator.pushNamedAndRemoveUntil(
          context, HomePage.routeName, (_) => false);
    else
      Navigator.pushNamed(context, LoginRegisterPage.routeName);
  }
  
  @override
  void dispose() {
      super.dispose();
    }
}

複製程式碼

App 結構

counterAppwidgertree.jpg

上圖是整個 Flutter App 的結構,從父節點開始分別是:

  1. MyApp: 整個 App 的入口在main.dartmain()函式中,呼叫 runApp(MyApp()),而 MyApp 也是一個 Widget,只不過用來定義一些全域性的內容,例如主題、多語言,路由
  2. MaterialApp: 一個 Material 風格的主題,對應的還有 CupertinoApp。
  3. MyHomePage MyHomePageState : 一個畫面,也是 Widget。
  4. Scaffold : 定義了一個畫面的一些基本效果,比如這裡 AppBar、滑動效果等採用 Material 風格,另外還有 ioS 風格的 CupertinoPageScaffold
  5. 剩下就是一些基本的元件。

一個基本的 main.dart 大概長這樣:

void main() async {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  static final navigatorKey = GlobalKey<NavigatorState>();
  static NavigatorState get navigator => navigatorKey.currentState;

  @override
  Widget build(BuildContext context) {
    return  CupertinoApp(
        title: '',
        theme: CupertinoThemeData(
          primaryColor: Color(0xFFFFFFFF),
          barBackgroundColor: Color(0xFF515669),
          scaffoldBackgroundColor: Color(0xFF3C3B45),
        ),
        navigatorKey: navigatorKey,
        routes: {
          HomePage.routeName: (_) => HomePage(),
          LoginRegisterPage.routeName: (_) => LoginRegisterPage(),
          LoginPage.routeName: (_) => LoginPage(),
          ForgetPswPage.routeName: (_) => ForgetPswPage(),
          RegisterPage.routeName: (_) => RegisterPage(),
        },
        ),
        home: SplashPage(),
    );
  }
}

複製程式碼
  • theme 定義了一個 ioS 風格的 CupertinoApp 主題(實際開發中可能需要同時使用 Material Cupertino 風格控制元件所以需要自定義主題)
  • routes 引數註冊路由表
  • home 引數設定首次載入的 Splash 畫面

路由

和 Web 中的路由類似,通過在路由表註冊相應的 url 和畫面。基本方法

  • push / pushNamed / pushNamedAndRemoveUntil/...
  • pop / popUntil / ...

基本使用:

// pushNamed 的定義
Future pushNamed(BuildContext context, String routeName,{Object arguments})

//開啟一個畫面,傳一個00
Navigator.of(context).pushNamed("home_page", arguments: '00');

//新畫面接受引數
var arg = ModalRoute.of(context).settings.arguments);

//關閉一個畫面,返回一個01
Navigator.of(context).pop(01);

複製程式碼
  • 實際上更好的方法來處理傳值的問題
  • 可以看到pushNamed方法返回值是一個Future ,說明是一個非同步操作,因為可以接受開啟的畫面pop關閉時返回的result ,此處在pop時返回了一個 01,那麼就可以這樣接收到。
var result = async Navigator.of(context).pushNamed("home_page", arguments: 'arg');
複製程式碼

網路請求和序列化

Flutter 的 網路請求庫沒有特別完美的,目前使用的是 Dio ,大致是一個簡化版的 okhttp 。

由於 Flutter 禁止使用反射,因為執行時反射會干擾 Dart 的 tree shaking,所以類似 Gson 這樣通過反射進行序列化的方式就行不通了。
目前大概的解決方案有兩種:

  • 手寫:Dio 會把返回值解析為 Map/List ,所以可以這樣手寫:
  Future<Profits> requestProfits() async {
    var response = await dio.get("u/profits");
    var data = response.data;
    print("requestProfits:$data");

    var profit = Profit()
      ..fund = data['profit']["fund"]
      ..profit = data['profit']["profit"]
      ..profitValue = toMoney(data['profit']['profitValue']);

    return Profits()
      ..miningProfit = data['miningProfit']
      ..lastMiningProfit = data['lastMiningProfit']
      ..profit = profit;
  }
複製程式碼
//user.dart

import 'package:json_annotation/json_annotation.dart';

// user.g.dart 將在我們執行生成命令後自動生成
part 'user.g.dart';

///這個標註是告訴生成器,這個類是需要生成Model類的
@JsonSerializable()

class User{
  User(this.name, this.email);

  String name;
  String email;
  //不同的類使用不同的mixin即可
  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);  
}
複製程式碼

當然,還是需要寫 fromJson toJson 的模板程式碼,也可以通過生成的方式解決。

平臺特定程式碼

Flutter 主要是負責了UI部分的構建,各平臺特定的程式碼還是要通過原生實現,主要用兩種方法處理:

  • Platform Channel : 大概就是 Flutter 端和原生端註冊約定好 platform_channel_namePlatform Channel ,然後呼叫方法和傳參,另一端解析就行了。具有原生能力的 plugin 也就是這樣實現的。比如
//flutter
MethodChannel('method_channel_mobile').invokeMethod('sendMobile','13000000000')

//Android MainActivity

MethodChannel(flutterView, MOBILE_CHANNEL)
            .setMethodCallHandler { methodCall, result ->
                when {
                    TextUtils.equals(methodCall.method, "mobile") -> {
                        mobile = methodCall.arguments.toString()
                        result.success("success")
                    }
                     result.notImplemented()
                }
            }
複製程式碼
  • PlatformView 直接巢狀原生的 View 到 Flutter 中,但這樣做效率不高。另外需要注意的是不要傳入一個 view 到PlatformView中,否則可能出現 Flutter 端多次呼叫該PlatformView的時候狀態會共存,以及不會銷燬。
// 定義一個用於的 PlatformView 和 PlatformViewFactory 用於例項化 Native View 
class ButtonFactory(
    private val context: Context
) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {

    override fun create(p0: Context?, p1: Int, p2: Any?): PlatformView {
        return ButtonPlatformView(context)
    }
    class ButtonPlatformView(
        private val context: Context
    ) : PlatformView {

        override fun getView(): Button {
            return Button(context)
        }
        override fun dispose() {
        }
    }
}

//在 MainActivity 中註冊
        registrarFor("native_view").platformViewRegistry()
            .registerViewFactory("native_view",ButtonPlatformFactory)
複製程式碼

Widget 巢狀的問題

網上對 Flutter 巢狀討論的比較多的問題就是,UI 複雜了以後,巢狀層數太多。 確實有這個問題,之前說了 Widget 不光是 View 還包括配置檔案,所以一個類似 Button 這樣的 Widget 可能就需要巢狀3 4層。
下面是我寫的一個登入畫面的登入按鈕,感受一下:

  CupertinoButton _loginButton() {
    return CupertinoButton(
      padding: EdgeInsets.all(0),
      child: Container(
          width: double.infinity,
          height: 45,
          decoration: BoxDecoration(
            gradient: LinearGradient(
                begin: Alignment.topCenter,
                end: Alignment.bottomCenter,
                colors: _isLoginAvailable
                    ? <Color>[Color(0xFF657FF8), Color(0xFF4260E8)]
                    : <Color>[Color(0xFFCBCFE2), Color(0xFF73788F)]),
            borderRadius: BorderRadius.all(Radius.circular(6)),
          ),
          child: Center(
            child: Text(
              "登 錄",
              textAlign: TextAlign.center,
              style: TextStyle(
                fontSize: 18,
                color: _isLoginAvailable ? Colors.white : Color(0x76FFFFFF),
                fontWeight: FontWeight.bold,
              ),
            ),
          )),
//      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
      onPressed: !_isLoginAvailable ? null : _startCustomFlow,
    );
  }
複製程式碼

實際上這還是隻是結構+樣式部分,不包括點選後的邏輯。
甚至你可以看到 Button 中的文字也是通過巢狀一個 Widget 來實現的,但這也是 Flutter 的一個優勢,不再需要寫自定義 Widget 的人去提供大量像文字能不能加粗,變色、斜體等等細節的樣式,直接讓你傳一個 Widget 自行處理,類似的情況還有很多。
另外一個問題是 Widget State 的狀態可能太多,包括各個 Widget 的狀態和畫面的狀態堆在一起,想起了當年原生 Android 一個 Activity 50個變數的恐懼。
但我認為這些主要還是因為 Flutter 處於發展的初期,還沒有太成熟的架構,目前官方提供了狀態管理的庫 Provider。 我目前的解決方案是儘量提成方法和獨立的Widget:

  • 對於有整個頁面無關區域性狀態的 Widget 提成一個獨立的 StatefulWidget
  • 對於沒有區域性狀態的,需要重用就提成一個StatelessWidget,不需要就抽成一個方法,返回 Widget,參考上面的 Button 。
  • 最後在build()方法中只描述整個畫面的結構。

例如一個 login 畫面的build()方法我是這樣寫的:

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: cusAppBar(context, elevation: 0),
        backgroundColor: $3C3B45,
        body: Stack(
          children: <Widget>[
            SingleChildScrollView(
              child: Container(
                margin: EdgeInsets.only(left: 15, right: 15),
                child: Column(
                  children: <Widget>[
                    _logo(),
                    Form(
                      onChanged: _onFormChanged,
                      child: Column(
                        children: <Widget>[
                          _phoneRow(),
                          _divider(),
                          _passwordColumn(),
                          _forgetPswText(),
                          _loginButton(),
                          _registerText()
                        ],
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
複製程式碼

熱過載(HotReload)

Flutter 的熱過載是廣受歡迎的一個特性,重要原因則是 Debug 模式採用 JIT 編譯,release 模式採用 AOT 編譯。實際用下來效果不錯。

問題

  • 編譯偶爾遇到的一個問題:
    問題:Waiting for another flutter command to release the startup lock...
    解決:rm ./flutter/bin/cache/lockfile
最後,以上只是總結一些重要的點,最終官方文件肯定是要讀一遍的,熟悉大部分 Widget 的用法: 文件

總結

優點:

  • Android iOs 兩端 UI 高度一致:由於 Flutter 使用自己的一套繪製 UI 的引擎和邏輯,完全不使用 Native View,僅僅呼叫原生的繪製介面,所以幾乎可以做到兩個平臺的 UI 一模一樣,這也是 Flutter 還要做 Web maCos 等全平臺的原因。我在開發期間一直使用 Android 進行除錯,最後在 Ios 上跑的時候,幾乎沒有什麼差別(雖然目前 UI 也不太複雜)。
  • 接入原生相對容易:需要原生實現的功能通過PlatformChannelPlatformView也大多都能實現,還可以通過PlatformChannel來啟動一個原生的Activity/Fragment實現。(比如掃一掃功能)
  • 貴族血統:Google 的全力支援,國內大廠也都在積極嘗試。
  • 初步可用的程度:目前已經完成了一個小專案的開發,在和原生互動不多的情況下還沒有遇到太大的坑。

缺點:

  • 基礎功能的缺失:很多基礎的功能也需要用 plugin 通過原生來實現,比如 Webview Map 這些元件,更不要說一些 SDK ,幾乎都需要自己寫 plugin。
  • 跨平臺的通訊:對於大量使用MethodChannel進行通訊以及各平臺間API有差異的情況下,設計和維護的問題。
  • 效能:目前原生 Flutter 在幀數上接近原生,使用者使用體驗接近,但記憶體開銷更大,尤其在視訊方面。

總的來說:我的看法是,比較看好 Flutter 跨更多平臺的前途,目前來說適合用來開發和原生平臺 API 互動不那麼複雜的 App 。