Flutter筆記整理[待拆分]

高晓牛發表於2024-09-05

1、getter、setter
set、get 方法是一對用來讀寫物件屬性的特殊方法,例項物件的每一個屬性都有一個隱式的 get 方法, 而且如果為非 final 屬性的話還會有一個 set 方法。

class Person{
  String _name = "li";

  //get 方法 : 置私有欄位的 get 方法 , 讓外界可以訪問類物件的私有成員 ;
  get name{
    return _name;
  }

  //set 方法 : 置私有欄位的 set 方法 , 讓外界可以設定類物件的私有成員 ;
  set name(value){
    _name = value;
  }
}

2、extension ... on 為指定類擴充套件額外的方法

extension addFuncToInt on int{
    getString(){ //為int類新增一個getString方法
       return this.toString();
    }
}

3、jumpTo(double offset)、animateTo(double offset,...):這兩個方法用於跳轉到指定的位置,它們不同之處在於,後者在跳轉時會執行一個動畫,而前者不會

4、在Flutter中監聽滾動相關的內容由兩部分組成:ScrollControllerScrollNotification。ListView、GridView的元件控制器是ScrollController,我們可以透過它來獲取檢視的滾動資訊,並且可以呼叫裡面的方法來更新檢視的滾動位置。

  var _controller = ScrollController();

  //監聽頁面的滾動
  _controller.addListener(() {
    print(_controller.offset);//滾動偏移量
  });

    return MaterialApp(
      home:ListView.builder(
        controller: _controller,
        itemExtent: 80,//高度
        itemCount: 100,
        itemBuilder:(BuildContext context, int index) {
          return GestureDetector(
              child: Text(name + index.toString()),
              onTap: (){
                //jumpTo(double offset)、animateTo(double offset,...):這兩個方法用於跳轉到指定的位置,它們不同之處在於,後者在跳轉時會執行一個動畫,而前者不會。
                _controller.animateTo(0, duration: Duration(seconds: 1), curve: Curves.ease);
                // _controller.jumpTo(0.0);
              },
          );
        },
      )

如果我們希望監聽什麼時候開始滾動,什麼時候結束滾動,這個時候我們可以透過NotificationListener

  • NotificationListener是一個Widget,模板引數T是想監聽的通知型別如果省略,則所有型別通知都會被監聽,如果指定特定型別,則只有該型別的通知會被監聽。
  • NotificationListener需要一個onNotification回撥函式用於實現監聽處理邏輯。該回撥可以返回一個布林值,代表是否阻止該事件繼續向上冒泡,如果為true時,則冒泡終止,事件停止向上傳播,如果不返回或者返回值為false 時,則冒泡繼續。
    body2(){
      // return NotificationListener<ScrollStartNotification>();//只監聽滾動開始型別的通知:ScrollStartNotification
      return NotificationListener(
          onNotification: (ScrollNotification notification){//onNotification回撥函式,用於實現監聽處理邏輯。
            if(notification is ScrollStartNotification){//滾動開始
                print("開始滾動");
            }else if(notification is ScrollUpdateNotification){//滾動中
              // 當前滾動的位置和總長度
              final currentPixel = notification.metrics.pixels;//當前滾動高度
              final totalPixel = notification.metrics.maxScrollExtent;//最大高度
              double progress = currentPixel / totalPixel;
              print("滾動中"+progress.toString());
            }else if(notification is ScrollEndNotification){//滾動結束
              print("結束滾動");
            }
    
            //該回撥可以返回一個布林值,代表是否阻止該事件繼續向上冒泡,如果為true時,則冒泡終止,事件停止向上傳播,如果不返回或者返回值為false 時,則冒泡繼續。
            return true;
          },
          child: ListView.builder(
              itemBuilder: (BuildContext context,int index){return Text("value"+index.toString());},
              itemCount: 30,
              itemExtent: 55,
          )
      );
    }

5、yaml裡面的的dependencies和dev_dependencies有什麼區別?

  • devDependencies是只會在開發環境下依賴的模組,生產環境不會被打入包內。 作為開發階段的一些工具包,主要用於幫助我們提高開發和測試效率,比如Flutter的自動化測試包等。
  • dependencies依賴的包不僅開發環境能使用,生產環境也能使用。作為APP的原始碼的一部分參與編譯,生成最終的安裝包

6、dart執行機制(訊息迴圈機制)
  Dart的耗時操作是透過單執行緒+事件迴圈方式來處理的。一些耗時操作,比如網路請求,都是放到事件迴圈來執行的,裡面存在一個事件佇列,事件迴圈不斷從事件佇列中取出事件執行。但除了事件佇列外,還存在一個微任務佇列。微任務佇列的優先順序要高於事件佇列;也就是說事件迴圈都是優先執行微任務佇列中的任務,再執行事件佇列中的任務;

那麼在Flutter開發中,哪些是放在事件佇列,哪些是放在微任務佇列呢?

  • 所有的外部事件任務都在事件佇列中,如IO、計時器、點選、以及繪製事件等;
  • 而微任務通常來源於Dart內部,並且微任務非常少。這是因為如果微任務非常多,就會造成事件佇列排不上隊,會阻塞任務佇列的執行(比如使用者點選沒有反應的情況);

Future是一個非同步操作,它表示一個可能在未來完成的任務。當您需要進行一些耗時的操作時,例如從伺服器獲取資料或者讀取本地儲存的檔案,這些任務可能需要一些時間才能完成。如果在同步模式下執行這些操作,應用程式可能會被阻塞,直到任務完成為止。但是,使用Future,您可以將這些操作放在後臺執行,而不會阻塞主執行緒。

import "dart:async";

main(List<String> args) {
//可以透過dart中scheduleMicrotask來建立一個微任務:
  scheduleMicrotask(() {
    print("Hello Microtask");
  });
}

Future的程式碼是加入到事件佇列還是微任務佇列呢?
Future中通常有兩個函式執行體:

  • Future建構函式傳入的函式體
  • then的函式體(catchError等同看待)

那麼它們是加入到什麼佇列中的呢?

  • Future建構函式傳入的函式體放在事件佇列中
  • then的函式體要分成三種情況:
  1. 情況一:Future沒有執行完成(有任務需要執行),那麼then會直接被新增到Future的函式執行體後;事件佇列
  2. 情況二:如果Future執行完後就then,該then的函式體被放到如微任務佇列,當前Future執行完後執行微任務佇列;
  3. 情況三:如果Future是鏈式呼叫,意味著then未執行完,下一個then不會執行;事件佇列
    // future_1加入到eventqueue中,緊隨其後then_1被加入到eventqueue中
    Future(() => print("future_1")).then((_) => print("then_1"));
    
    // Future沒有函式執行體,then_2被加入到microtaskqueue中
    Future(() => null).then((_) => print("then_2"));
    
    // future_3、then_3_a、then_3_b依次加入到eventqueue中
    Future(() => print("future_3")).then((_) => print("then_3_a")).then((_) => print("then_3_b"));

程式碼執行順序

import "dart:async";

main(List<String> args) {
  print("main start");

  Future(() => print("task1"));
    
  final future = Future(() => null);

  Future(() => print("task2")).then((_) {
    print("task3");
    scheduleMicrotask(() => print('task4'));
  }).then((_) => print("task5"));

  future.then((_) => print("task6"));
  scheduleMicrotask(() => print('task7'));

  Future(() => print('task8'))
    .then((_) => Future(() => print('task9')))
    .then((_) => print('task10'));

  print("main end");
}

程式碼執行的結果是:

main start
main end
task7
task1
task6
task2
task3
task5
task4
task8
task9
task10

程式碼分析:
1、main函式先執行,所以main start和main end先執行,沒有任何問題;
2、main函式執行過程中,會將一些任務分別加入到EventQueue和MicrotaskQueue中;
3、task7透過scheduleMicrotask函式呼叫,所以它被最早加入到MicrotaskQueue,會被先執行;
4、然後開始執行EventQueue,task1被新增到EventQueue中被執行;
5、透過final future = Future(() => null);建立的future的then被新增到微任務中,微任務直接被優先執行,所以會執行task6;
6、一次在EventQueue中新增task2、task3、task5被執行;
7、task3的列印執行完後,呼叫scheduleMicrotask,那麼在執行完這次的EventQueue後會執行,所以在task5後執行task4(注意:scheduleMicrotask的呼叫是作為task3的一部分程式碼,所以task4是要在task5之後執行的)
8、task8、task9、task10一次新增到EventQueue被執行;

7、Isolate

dart雖然是單執行緒的模型,但是也提供了一個執行緒的封裝——Isolate。不同於其他平臺的多執行緒模型,每個isolate都有自己獨立的執行執行緒和事件迴圈,以及記憶體,所以isolate之間不存在鎖競爭的問題,各個isolate之間透過訊息通訊。

  Dart是單執行緒的,但Flutter並不是。Flutter內部有很多執行緒同時工作,比如Flutter中就有一個Root Isolate,負責執行Flutter的程式碼。除此之外,還有其他執行緒,比如Ul Runner Thread、GPU Runner Thread、IO Runner Thread、Platform Runner Thread。

  我們一般透過Isolate.spawn就可以建立一個Isolate,該方法需要傳兩個引數,一個是需要放到Isolate裡面執行的耗時操作,另一個則是message,用來負責不同Isolate間的通訊

  Isolate.spawn( (number) {
      //下面這段耗時程式碼將被放到另一個執行緒操作,不會阻塞rootIsolate
      for(int i = 0;i<number;i++){
         sleep(Duration(seconds: 1));
      };
      print("i calculate finished");
  },cycleNum);

  因為不同Isolate是相互隔離的,所以如果想要通訊的話,比如需要把某個圖片放到該Isolate中處理,處理完後在返回給主Isolate。這樣的話需要藉助ReceivePort這個類來搭建管道從而完成雙方通訊,分為單向通訊和雙向通訊,較為繁瑣。
  所以Flutter提供了支援併發計算的compute函式,它內部封裝了Isolate的建立和雙向通訊。

//耗時操作
  timeFunc(num){
    int value = 0;
    for(int i = 0;i<num;i++){
      sleep(Duration(seconds: 1));
      value += i;
    };
    return value;
  }

  //compute函式會將耗時操作timeFunc放到一個Isolate中去執行,而且也封裝了雙向通訊,既可以將rootIsolate中的引數傳遞到新建的Isolate中去,可以將Isolate的返回值傳遞出來。
  var value = await compute(timeFunc,20);

什麼場景該使用Future還是isolate
Future事件迴圈也可以不阻塞的情況下完成耗時操作,Isolate也可以。那麼如何判斷某個耗時操作該選用哪種方式呢?
* 建議儘可能地使用 Future(直接或間接地透過 async 方法),因為一旦事件迴圈擁有空閒時間,這些 Future 的程式碼就會被執行。如果繁重的處理可能需要一些時間才能完成,並且可能影響應用的效能,考慮使用 Isolate

* 另外一個可以幫助你決定使用 Future 或 Isolate 的因素是執行某些程式碼所需要的平均時間。
如果一個方法需要幾毫秒 => Future
  如果一個處理流程需要幾百毫秒 => Isolate

* 以下是一些很好的 Isolate 選項:
  JSON 解碼:解碼 JSON(HttpRequest 的響應)可能需要一些時間 => 使用 compute
  加密:加密可能非常耗時 => Isolate
  影像處理:處理影像(比如:剪裁)確實需要一些時間來完成 => Isolate
  從 Web 載入影像:該場景下,為什麼不將它委託給一個完全載入後返回完整影像的 Isolate?


8、記憶體物件管理大致分兩部分,一個是iOS的引用計數銷燬機制,一個是垃圾回收機制,Dart語言的記憶體管理和Java他們一樣是垃圾回收。
Dart垃圾回收的策略可以簡單概括為:"分代"GC。即垃圾回收分代為:"新生代","老生代"。Dart還專門設計了排程器(在引擎中hooks的),當檢測到空閒且沒有使用者互動時進行GC操作。

  • 新生代主要是清理一些壽命很短的物件,比如StatelessWidget。

    新物件被分配到連續、可用的記憶體空間,這個區域包含兩個部分:活躍區和非活躍區,新物件在建立時被分配到活躍區、一旦填充完畢,仍然活躍的物件會被移動到非活躍區,不再活躍的物件會被清理掉,然後非活躍區變成活躍區,活躍區變成非活躍區,以此迴圈。(註解:GC來完成上面的步驟)

    為了確定哪些Object是存活的或死亡的,GC從根物件開始檢測。然後將有引用的Object(存活的)移動到非活動狀態,直接所有存活的Object被移動。死亡的Object就被留下清除;

  • 新生代階段未被回收的物件,將會由老生代收集器管理新的記憶體空間:mark-sweep。

    在老生代收集器的管理分為兩個階段:階段1:遍歷物件圖,然後標記在使用的物件;階段2:掃描整個記憶體,並且回收所有未標記的物件

9、Widget-Element-RenderObject

  • Widget 的主要作用是用來儲存 Element 資訊的(包括佈局、渲染屬性、事件響應等資訊),本身是不可變的,Element 也是根據 Widget 裡面儲存的配置資訊來管理渲染樹,以及決定自身是 否需要執行渲染。
  • RenderObject 做為渲染樹中的物件存在,主要作用是處理佈局、繪製相關的事情,而繪製的內容是Widget傳入的內容。
  • Element 可以理解為是其關聯的 Widget 的例項, 承載了檢視構建的上下文資料,是連線結構化的配置資訊到完成最終渲染的橋樑。可以透過遍歷Element來檢視檢視樹,Element同時持有Widget和RenderObject物件。

  widget 樹和 Element 樹節點是一一對應關係,每一個 Widget 都會有其對應的 Element,但是 RenderObject 樹則不然,並不是所有widget都有對應的RenderObject,只有需要渲染的 Widget 才會有對應的節點。Element 樹相當於一箇中間層,大管家,它對 Widget 和 RenderObject 都有引用。當 Widget 不斷變化的時候,將新 Widget 拿到 Element 來進行對比,看一下和之前保留的 Widget 型別和 Key 是否相同,如果都一樣,那完全沒有必要重新建立 Element 和 RenderObject,只需要更新裡面的一些屬性即可,這樣可以以最小的開銷更新 RenderObject,引擎在解析 RenderObject 的時候,發現只有屬性修改了,那麼也可以以最小的開銷來做渲染。

  關於Widget、Element、RenderObject 的總結。
  我們寫好 Widget 樹後,Flutter 會在遍歷 Widget 樹時呼叫 Widget 裡面的 createElement 方法去生成對應節點的 Element 物件,同時 Element 裡面也有了對 Widget 的引用。特別的是當 StatefulElement 建立的時候也執行 StatefulWidget 裡面的 createState 方法建立 state,並且賦值給 Element 裡的 _state 屬性,當前 widget 也同時賦值給了 state 裡的_widget。Element 建立好以後 Flutter 框架會執行 mount 方法,對於非渲染的 ComponentElement 來說 mount 主要執行 widget 裡的 build 方法,而對於渲染的 RenderObjectElement 來說 mount 裡面會呼叫 widget 裡面的 createRenderObject 方法 生成 RenderObject,並賦值給 RenderObjectElement 裡的相應屬性。StatefulElement 執行 build 方法的時候是執行的 state 裡面的 build 方法,並且將自身傳入,也就是 常見的 BuildContext。
  如果 Widget 的配置資料發生了改變,那麼持有該 Widget 的 Element 節點也會被標記為 dirty。在下一個週期的繪製時,Flutter 就會觸發該 Element 樹的更新,透過 canUpdate 方法來判斷是否可以使用新的 Widget 來更新 Element 裡面的配置,還是重新生成 Element。並使用最新的 Widget 資料更新自身以及關聯的 RenderObject物件。佈局和繪製完成後,接下來的事情交給 Skia 了。在 VSync 訊號同步時直接從渲染樹合成 Bitmap,然後提交給 GPU。


疑問①:為什麼需要Element樹,直接按照Widget樹去渲染顯示不行嗎?
  答案是不行的,因為Widget非常不穩定,動不動就會執行Build方法,也就是這個Widget依賴的所有其他Widget都需會重新建立。如果Flutter直接解析Widget樹,將其轉化為RenderObject樹去渲染的話,將會非常消耗效能。因此,這裡就有另外一棵樹 Element 樹。Element 樹這一層將 Widget 樹的變化做了判斷,可以只將真正需要修改的部分同步到真實的 RenderObject 樹中去重新渲染,其他部分則不變。從而最大程度降低對真實渲染檢視的修改,提高渲染效率,而不是銷燬整個渲染檢視樹重建。

疑問②:Widget變化時,Element樹怎麼知道該Widget需不需要重新建立渲染呢?
  如果 Widget 的配置資料發生了改變,那麼持有該 Widget 的 Element 節點也會被標記為 dirty。在下一個週期的繪製時,Flutter 就會觸發該 Element 樹的更新,透過 canUpdate 方法來判斷是否可以使用新的 Widget 來更新 Element 裡面的配置,還是重新生成 Element。而canUpdate 方法將新 Widget 拿到 Element 來進行對比,看一下和之前保留的 Widget 型別和 Key 是否相同,如果都一樣,那完全沒有必要重新建立 Element 和 RenderObject,只需要更新裡面的一些屬性即可,反之則需要重新生成。

static bool canUpdate(Widget oldWidget, Widget newWidget) {
    return oldWidget.runtimeType == newWidget.runtimeType
        && oldWidget.key == newWidget.key;
  }

疑問③為什麼Widget和Element是一一對應的,但RenderObject卻不是一一對應的呢?
  這是因為Widget這個抽象類裡面有個抽象方法createElement(),也就意味著所有的Widget子類必須實現此方法,所以Widget 和 Element 是一一對應的。而RenderObject 和 widget 並不是一一對應的,只有繼承自 RenderObjectWidget 的 widget 才有對應的 RenderObject。Widget分為兩類可渲染的Widget和不可渲染的Widget,其中,可渲染的Widget又可分MultiChildRenderObjectWidget(比如RichText)和SingleChildRenderObjectWidget(比如Pading),而不可渲染的Widget分為StatelessWidget(比如Container)和StatefulWidget(比如TextField)。只有可渲染的Widget才有對應的RenderObject。

疑問④:Widget、Element、RenderObject分別做了什麼工作?
Widget只是描述了配置資訊:

  • 首先,所有Widget其中都包含createElement方法,用於建立Element;
  • 定義了canUpdate 方法以供Element物件執行,從而判斷Widget修改後需不需要重新建立渲染。
  • 對於RenderObjectWidget類的話,定義了createRenderObject,但不是Widget自己在呼叫
  • StatefulWidget類也定義了createState,同樣不是Widget自己呼叫
  • 而對於StatelessWidget類,則定義了build()函式(StatefulWidget會透過state呼叫build())

Element是真正儲存樹結構的物件:

  • 建立出來後會由framework呼叫mount()這個核心方法,將這個新建立的element掛載到 Element 樹上給定的父節點的插槽下面。
  • 對於StatelessWidget的Element物件來說,直接呼叫了其Build方法;
  • 對於StatefulWidget的Element物件,則呼叫了其createState方法並賦值給 _state,同樣也呼叫了其_state中的build方法;
  • 而對於RenderObjectElement物件來說,在mount方法中會呼叫widget的createRenderObject方法,生成RenderObject物件;
  • Element對widget和RenderObject都有引用,它作為中間者,管理著雙方。

RenderObject是真正渲染的物件:
  其中有markNeedsLayout performLayout markNeedsPaint paint等方法。RenderObject 在 Flutter 的展示分為四個階段,即佈局、繪製、合成和渲染。其中,佈局和繪製在 RenderObject 中完成,Flutter 採用深度優先機制遍歷渲染物件樹,確定樹中各個物件的位置和尺寸,並把它們繪製在不同的圖層上。繪製完畢後,合成和渲染的工作則交給 Skia 搞定。

疑問⑤:Widget的Build方法中在什麼時候呼叫,BuildContext引數又是什麼?
  是Element
  在StatelessElement類中,我們發現呼叫了Widget的build方法,並且將this傳入,這個this 就是element ,所以本質上BuildContext就是當前的Element;
  在StatefulElement中,build方法也是類似,只不過呼叫的是state的build方法,傳入的是同樣是this。

class StatelessElement extends ComponentElement {
  StatelessElement(StatelessWidget super.widget);

  @override
  Widget build() => (widget as StatelessWidget).build(this);
}


class StatefulElement extends ComponentElement {
  StatefulElement(StatefulWidget widget)
      : _state = widget.createState(),
        super(widget) {
    ... 省略斷言 ...
      }
      return true;
    }());
    ... 省略斷言 ...;
    state._element = this;
     ... 省略斷言 ...
    state._widget = widget;
     ... 省略斷言 ...
  }

  @override
  Widget build() => state.build(this);

}

疑問⑥:Widget 頻繁更改建立是否會影響效能?複用和更新機制是什麼樣的?
  不會影響效能,widget 只是簡單的配置資訊,並不直接涉及佈局渲染相關。Element 層透過執行Widget定義的canUpdate方法,判斷新舊兩個widget 的runtimeType 和 key 是否相同,從而決定是否可以直接更新之前的配置資訊,而不必每次都重新建立新的 Element。

疑問⑦:createState 方法什麼時候呼叫?
  建立Element 的時候。
我們來看一下StatefulElement的構造器:在StatefulElement 這個類中,構造器的初始化列表的給state 進行了賦值操作。透過widget呼叫createState方法之後,把state賦值給自己的_state 屬性。

  StatefulElement(StatefulWidget widget)
      : _state = widget.createState(),
  state._element = this;
  ....省略程式碼
  _state._widget = widget;
  • 呼叫widget的createState()
  • 所以StatefulElement對建立出來的State是有一個引用的
  • 而_state又對widget和element有一個引用

疑問⑧:真正的渲染相關的程式碼在哪裡執行呢
  RenderObject

10、Key
  在Flutter中,Key是不能重複使用的,所以Key一般用來做唯一標識,用於標識Widget的物件。元件在更新的時候,其狀態的儲存主要是透過判斷元件的型別或者key值是否一致來決定是否需要重新建立。因此,當各元件的型別不同的時候,型別已經足夠用來區分不同的元件了,此時我們可以不必使用key。但是如果同時存在多個同一型別的控制元件的時候,此時型別已經無法作為區分的條件了,我們就需要使用到key。
  舉個簡單的例子,現有一個List列表,cell上只有一個標題和隨機的背景色,標題顯示標題陣列中對應序列號的文字內容。現在要求點選底部按鈕後刪掉頂部第一個cell,(即【紅1 綠2 黃3】變為【綠2 黃3】)。如果我們不使用key來標記Widget的話,你會發現,結果並不是這樣,結果分兩種:

  • · 如果該Widget是StatelessWidget的話,操作後,第一個cell確實會被刪除,但是剩餘cell的背景色也會發生變動,(即【紅1 綠2 黃3】變為【藍2 紫3】)。這是因為每次刪除後,都會重新執行Widget的Build方法,而Build方法中又會重新生成新的隨即色
  • · 而如果該Widget是StateFullWidget的話,操作後,發現顏色不變化,但是資料向上移動了(即【紅1 綠2 黃3 】變為【紅2 綠3】)。這是因為每次刪除重新整理後,Widget的複用導致的。Element物件判斷該Widget是可以複用還是重新建立就是透過元件的型別或者key值是否一致來決定。因為我們沒有設定key,所以只需要判斷元件的型別是否一致就行。當我們修改資料來源後,Element發現widget樹中第一位置新的Widget和舊的widget一致(因為型別一樣),因此就直接複用了,只需要將標題1改為標題2即可;同樣的道理,Element發現,Widget樹中第二位置上新的Widget與舊的Widget一致,也就修改了數字進行復用。而至於第三個Widget,Element發現現在只需要兩個Widget就行,所以直接將第三個Widget刪掉了。

   所以,如果想達到預期效果,就需要設定Key。這樣Widget在複用的時候,就需要不能只看型別了。比如我們為每個Widget設定一個ValueKey,給定的值就是當前Widget的標題。這樣,當刪除資料後重新整理時,Element想複用Widget時,發現第一個Widget雖然型別相同,但是舊Widget的key是1,而要顯示的新Widget的key是2,兩個key不同,無法複用,所以會繼續在同級目錄下逐個查詢,找到第二個Widget時發現舊Widget的key和型別與新Widget的一致,所以會被儲存下來複用。相反,如果沒有找到一致的,那麼就會被銷燬而重新建立。

  如果設定的是UniqueKey呢? 就會發現每次刪除都會出現隨機顏色的現象。這是因為每次重新整理時都會生成一個新的Key,沒辦法複用,所以Element會強制重新整理,那麼對應的State也會重新建立。


Key的主要作用包括:

  • 識別Widget:Key可以用於在Widget樹中唯一標識一個Widget,當需要查詢或比較Widget時,Flutter框架會使用Key來進行操作,可以快速查詢和比較Widget。
  • 複用Widget:當Flutter框架需要重建Widget樹時,它會檢查是否有相同的Key的Widget可以複用,而不是每次都重新建立新的Widget,以提高效能。
  • 控制重建:在某些情況下,使用Key可以控制Widget的重建。例如,當使用ListView或GridView等可滾動元件時,可以透過為每個item指定一個唯一的Key來控制哪些item需要重新構建,以提高效能。

在Flutter中,key的分類主要包括以下幾種:

  • LocalKey

  用於在同一父Element下的Widget之間進行比較,也是diff演算法的核心所在。它主要有3種分類:
   1. ValueKey:值鍵,使用給定的值來比較和匹配Widget,通常用於列表或集合中的專案,以確保正確的更新和操作。
    2. ObjectKey:物件鍵,ObjectKey判斷兩個Key是否相同的依據是:兩個物件是否具有相同的記憶體地址,通常用於保持特定物件的身份和狀態。
    3. UniqueKey:唯一鍵,不需要引數,並且每一次重新整理都會生成一個新的Key,主要用於動畫的重新整理,或用於需要每次都要重建重新整理widget的場景

  • GlobalKey:全域性鍵,用於在整個應用程式中標識和訪問特定的Widget。GlobalKey可以跨Widget層級使用,用於在不同的Widget樹中查詢和操作特定的Widget。比如在不同的螢幕上使用相同的Widget,但是保持相同的State,則需要使用GlobalKeys(例如在A頁面將開關從關閉設定為開啟,B頁面的開關狀態也會隨之改變)。

    Global keys 是很昂貴的,如果你不需要訪問BuildContext、Element、State這些的話,請儘量使用LocalKey。

用途:獲取配置、狀態以及元件位置尺寸等資訊:使用GlobalKey可以獲取特定Widget的狀態物件,以便在需要時進行操作或訪問。例如,可以使用GlobalKey來獲取表單欄位的文字輸入框的當前文字值。
(1)_globalKey.currentWidget:獲取當前元件的配置資訊(存在widget樹中)
(2)_globalKey.currentState:獲取當前元件的狀態資訊(存在Element樹中)
(3)_globalKey.currentContext:獲取當前元件的大小以及位置資訊。


11、StatefulWidget 的生命週期? StatefulWidget有哪些生命週期的回撥呢?它們分別在什麼情況下執行呢?
我們知道StatefulWidget本身由兩個類組成的:StatefulWidget和State,我們分開進行分析。
  首先,執行StatefulWidget中相關的方法:
    1、執行StatefulWidget的建構函式(Constructor)來建立出StatefulWidget;
    2、執行StatefulWidget的createState方法,來建立一個維護StatefulWidget的State物件;
  其次,呼叫createState建立State物件時,執行State類的相關方法:
    1、執行State類的構造方法(Constructor)來建立State物件;
    2、執行initState,我們通常會在這個方法中執行一些資料初始化的操作,或者也可能會傳送網路請求
    3、執行didChangeDependencies方法,這個方法在兩種情況下會呼叫
      情況一:呼叫initState會呼叫;
      情況二:從其他物件中依賴一些資料發生改變時,比如InheritedWidget;
    4、Flutter執行build方法,來看一下我們當前的Widget需要渲染哪些Widget;
    5、當前的Widget不再使用時,會呼叫dispose進行銷燬;
    6、手動呼叫setState方法,會根據最新的狀態(資料)來重新呼叫build方法,構建對應的Widgets;
    7、執行didUpdateWidget方法是在當父Widget觸發重建(rebuild)時,系統會呼叫didUpdateWidget方法;

12、Dart 當中的 「…」表示什麼意思?
級聯運算子(…)可以讓你在同一個物件上連續呼叫多個物件的變數或方法。

querySelector('#confirm') // 獲取物件 (Get an object).
  ..text = 'Confirm' // 使用物件的成員 (Use its members).
  ..classes.add('important')
  ..onClick.listen((e) => window.alert('Confirmed!'));

第一個方法 querySelector 返回了一個 Selector 物件,後面的級聯運算子都是呼叫這個 Selector 物件的成員並忽略每個操作的返回值。

錯誤用法:

var sb = StringBuffer();
sb.write('foo')
  ..write('bar'); // 出錯:void 物件中沒有方法 write (Error: method 'write' isn't defined for 'void').

上述程式碼中的 sb.write() 方法返回的是 void,返回值為 void 的方法則不能使用級聯運算子。


13、extends(繼承), implements(介面實現), mixin(混入)
extends(繼承),在flutter中繼承是單繼承。
  子類重寫超類的方法要用@override
  子類呼叫超類的方法要用super
  子類會繼承父類裡面可見的屬性和方法,但是不會繼承建構函式
  子類能複寫父類的getter 和 setter 方法
  子類可以繼承父類的非私有變數
繼承的侷限在於:在flutter中只能單繼承,靈活度不高。所以有後面的這兩個implements、和mixin來彌補。

implements(介面實現)
  可多個介面實現(任何單獨的都很蒼白,對比才能更立體)。介面定義要實現的屬性和方法的命名,具體實現需要在每一個具體的類中體現,且子類需要全部實現implements後的類的所有屬性和方法。
  介面的侷限在於:一個子類必須全部實現所有的屬性和方法。mixin可以解決這個問題

mixin(混入),在現有類的基礎上,引入一些新的變數。
  作為mixins 的類只能繼承自object,不能繼承其他的類。
  作為mixins 的類不能有建構函式。
  一個類可以mixins 多個mixin 類。
  mixins 不是繼承,也不是介面,而是一種全新的特性。

關鍵字:
  with:子類混入某個類的時候使用
  on:定義基於某個型別的mixin,即限制mixin只能應用於特定型別的類,這就代表了在mixin中可以訪問到該特定類的成員和方法。
  三者可以同時存在於一個類中,前後順序是:extends>mixins>implements
  如果都使用了同一個方法的實現,那麼在子類中的這個方法的有效性優先順序:mixins>extend>implements, mixins和implements中如果跟了多個,那麼後面的會覆蓋前面的,沒有衝突的,則都會保留,所以會存在後面的會修改掉前面的一部分邏輯程式碼,不需要直接繼承,就可以直接實現覆蓋,避免了更復雜的多繼承關係。


14、Flutter渲染流程是什麼?
  Flutter的渲染流程是將Widget轉換為顯示在螢幕上的畫素的過程。它涉及多個階段,包括構建、佈局、繪製和合成。

  • 根據Widget 生成Element,然後建立相應的RenderObject並且關聯到Element.renderObject 屬性。最後再透過RenderObject 來完成佈局和繪製。
  • 當需要更新頁面的時候,由應用上層通知到Engine,Engine會等到下個Vsync訊號到達的時候,去通知Framework上層,然後Framework會進行Animation, Build,Layout,Compositing,Paint,最後生成layer提交給Engine。Engine會把layer進行組合,生成紋理,最後透過OpenGl介面提交資料給GPU, GPU經過處理後在顯示器上面顯示。

【Engine:一個 C++實現的 SDK。其包含了 Skia引擎、Dart執行時、文字排版引擎等。在安卓上,系統自帶了Skia,在iOS上,則需要APP打包Skia庫,這會導致Flutter開發的iOS應用安裝包體積更大。 Dart執行時則可以以 JIT、JIT Snapshot 或者 AOT的模式執行 Dart程式碼】


15、狀態管理 (https://juejin.cn/post/6920852250667532301


  狀態管理就是當某個狀態發生改變的時候,告知使用該狀態的狀態監聽者,讓狀態所監聽的屬性隨知改變,從而達到聯動效果。這也是響應式程式設計和指令式程式設計區別,Android、iOS是透過明確的命令式指令去控制我們的UI變化。而在響應式程式設計下,我們只需要描述好UI和狀態之間的關係,然後專注於狀態的改變就好了,框架會根據狀態的變化來自動更新UI【觀察者模式】。
  Flutter 狀態管理是指在 Flutter 應用中有效地管理應用的資料和狀態,以確保使用者介面(UI)與資料之間的一致性和互動性。在複雜的應用中,資料通常會在不同的部分之間流動和變化,而狀態管理的目標是幫助開發者更好地組織、更新和共享這些資料。

狀態管理分為短時狀態和應用狀態兩類:

  • 短時狀態:只需要在一個獨立widget中使用,Widget樹中的其它部分並不需要訪問這個狀態,這種狀態我們只需要使用StatefulWidget對應的State類自己管理即可。
  • 應用狀態:開發中也有非常多的狀態需要在多個部分進行共享,比如使用者的登入狀態資訊,這種狀態我們如果在Widget之間傳遞來、傳遞去,所以需要用全域性狀態管理的方式,來對狀態進行統一的管理和應用。

Flutter中目前有哪些可以做到狀態管理,有什麼優缺點?
答:State、 InheritedWidget、 Notification、 Stream資料流。優缺點詳見上面連結

狀態管理方案有哪些?
Flutter狀態管理方案目前有很多種,有官方推薦的,也有優秀的三方框架,分類如下:

  • Flutter 本身支援:

    State、 InheritedWidget、 Notification、 Stream 資料流

  • 官方推薦:

    Provider
    Redux
    BLoC/Rx
    MobX

  • 三方優秀框架:

    scoped_model
    閒魚Fish-Redux

State自不必多說,接下來我們主要看InheritedWidget和Provider這兩種方案:
  InheritedWidget元件特別適合在同一樹型Widget中,抽象出公有狀態,每一個子Widget或者孫Widget都可以獲取該狀態。當InheritedWidget資料發生變化時,可以自動更新依賴的子孫元件!
InheritedWidget的優點是較輕量,但也有以下幾個缺點:
  1.每次更新都會通知所有的子Widget,無法定向通知/指向性通知,容易造成不必要的重新整理
  2.不支援跨頁面(route)的狀態,意思是跨樹,如果不在一個樹中,我們無法獲取
  3.資料是不可變的,必須結合StatefulWidget、ChangeNotifier或者Steam使用

InheritedWidget的使用分為以下步驟:

  • 建立

    ①將需要跨元件共享的資料儲存在一個繼承自InheritedWidget的widget中;
    ②建立一個包含共享資料(用於設定共享資料的數值)和child(設定要獲取資料的依賴子控制元件)的建構函式;
    ③構建一個of的Static函式,方便子控制元件拿到該Widget,進而拿到共享資料;
    ④實現updateShouldNotify函式,返回一個布林值,,決定是否對StatefulWidget子控制元件的didChangeDependencies方法進行回撥;

  • 使用

    ⑤透過InheritedWidget子類的建構函式的引數分別設定初始化貢獻資料和包裹要用到該資料的子控制元件;
    ⑥結合StatefulWidget等對資料修改後,重新執行建構函式更改共享資料的值;
    ⑦需要獲取共享資料的子控制元件,呼叫InheritedWidget子類的of方法,透過上下文拿到該Widget,進而拿到共享資料。

class UserInfoViewModelWidget extends InheritedWidget{
  // 1.共享的資料
  final int counter;

  // 2.定義構造方法,用於初始化時設定共享資料
  UserInfoViewModelWidget({required  this.counter, Widget child}):super(child:child);

  // 3.獲取元件最近的當前InheritedWidget,方便子控制元件透過該InheritedWidget拿到共享資料
  static UserInfoViewModelWidget? of(BuildContext context) {
    // 沿著Element樹, 去找到最近的UserInfoViewModelElement, 從Element中取出Widget物件
    return context.dependOnInheritedWidgetOfExactType();
  }

  // 4.決定要不要回撥子控制元件State中的didChangeDependencies(前提子空間得是StateFullWidget)
  // 如果返回true: 執行依賴當期的InheritedWidget的State中的didChangeDependencies
  @override
  bool updateShouldNotify(UserInfoViewModelWidget oldWidget) {
    return oldWidget.counter != counter;
  }
}

//在子元件中引用InheritedWidget。
class MyApp2 extends StatefulWidget {
  const MyApp2({super.key});

  @override
  State<StatefulWidget> createState() => _MyAppState();
}

class _MyAppState extends State{
  var _counter = 0;
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home:Scaffold(
        body:UserInfoViewModelWidget(  //因為 InheritedWidget 是從上到下進行資料共享、傳遞的,所以要把 InheritedWidget 作為根節點,需要共享資料的節點作為子節點。
            counter:_counter,
            child:body4() //只有子節點才能拿到共享的資料
        ),
        floatingActionButton: FloatingActionButton(
            child: Icon(Icons.navigate_next,color: Colors.white),
            onPressed: (){
              setState(() {//結合StatefulWidget重新初始化UserInfoViewModelWidget,重新設定共享資料的值
                _counter += 1;
              });
            }
        ),
      ),
    );
  }
}

class body4 extends StatelessWidget{
  @override
  Widget build(BuildContext context) {
    return Center(
      child: Card(//子控制元件透過 of 方法使用了 InheritedWidget 中的資料,註冊依賴關係
          child: Text("${UserInfoViewModelWidget.of(context)?.counter}",style: TextStyle(color: Colors.pink,fontSize: 20),)
      ),
    );
  }
}

接下來再看Provider,從名字上就很容易理解,它就是用於提供資料,無論是在單個頁面還是在整個app 都有它自己的解決方案,可以很方便的管理狀態。Provider的實現在內部還是利用了InheritedWidget實現的,但其使用更加簡潔高效。Provider是一個觀察者模式, 狀態改變時要notifyListeners().

常用概念:

  • ChangeNotifier:系統提供的被觀察者,需要共享的資料都需要放在這裡,資料共享的類需要繼承或混入ChangeNotifier。共享資料一般設定為私有,然後提供setget方法,在set方法中監聽資料的改變,呼叫notifyListeners方法,通知所有消費者進行更新。
  • Provider:訂閱者,只用於資料共享管理,提供給子孫節點使用。因為Provider是基於InheritedWidget,所以我們在使用ChangeNotifier中的資料時,我們可以透過Provider.of的方式來使用,但不太推薦這種方法,因為 Provider.of所在的Widget整個build方法都會重新構建。
  • ChangeNotifierProvider:訂閱者,不僅能夠提供資料供子孫節點使用,還可以在資料改變的時候通知所有消費者。 Model變化後會自動通知ChangeNotifierProvider(訂閱者),ChangeNotifierProvider內部會重新構建InheritedWidget,而依賴該InheritedWidget的子孫Widget就會更新.
  • MultiProvider:多個訂閱者:實際上就是透過每一個provider都實現了的 cloneWithChild方法把自己一層一層包裹起來。
  • Consumer:消費者,能夠在複雜專案中,極大地縮小你的控制元件重新整理範圍。最多支援6中model
  • Selector: 消費者,強化的Consumer,支援過濾重新整理

Provider種類:

  • Provider:只能提供恆定的資料,不能通知依賴它的子部件重新整理
  • ListenableProvider: 提供的物件是繼承了 Listenable 抽象類的子類,必須實現其 addListener / removeListener 方法,通常不需要
  • ChangeNotifierProvider: 對子節點提供一個繼承/混入/實現了ChangeNotifier的類,只需要在Model中with ChangeNotifier ,然後在需要重新整理狀態時呼叫 notifyListeners 即可
  • ValueListenableProvider: 提供實現了繼承/混入/實現了ValueListenable的Model,實際上是專門用於處理只有一個單一變化資料的ChangeNotifier。
  • StreamProvider: 專門用作提供(provide)一條 Single Stream。
  • FutureProvider:提供了一個 Future 給其子孫節點,並在 Future 完成時,通知依賴的子孫節點進行重新整理

介紹完概念後,我們來看下一具體怎麼使用:
建立:
①Provider雖然是Flutter官方提供的,但並還是需要我們對它引入依賴

dependencies:
    provider: ^6.1.2

②建立自己的ChangeNotifier來管理共享資料,可以繼承自ChangeNotifier,也可以使用混入,這取決於機率是否需要繼承自其它的類。
③將共享資料設定為私有屬性,然後提供setget方法,在set方法中監聽資料的改變後,呼叫notifyListeners方法,通知所有消費者進行更新。
使用:
④在應用程式的頂層插入ChangeNotifierProvider(即包裹住MyApp控制元件),這樣方便在整個應用的任何地方可以使用自定義的ChangeNotifier,以便Consumer可以獲取到資料。create引數則是返回被觀察者的函式;
⑤在需要的位置使用共享的資料,有以下幾種方式:
  * > Provider.of: 當Provider中的資料發生改變時, Provider.of所在的Widget整個build方法都會重新構建;
  * > Consumer(相對推薦): 當Provider中的資料發生改變時, 執行重新執行Consumer的builder;
  * > Selector: 1.selector方法(作用,對原有的資料進行轉換) 2.shouldRebuild(作用,可選擇要不要重新構建)

Ⅰ建立的過程比較簡單,直接上程式碼:

import 'package:flutter/cupertino.dart';
import 'package:flutter_test_project/models/UserInfo.dart';

class UserInfoProvider with ChangeNotifier{
    UserInfo _uinfo = UserInfo("", 0);

    UserInfo get uinfo => _uinfo;

    set uinfo(UserInfo value) {
      _uinfo = value;
      //資料發生修改後,傳送通知
      notifyListeners();
    }
}

而在使用的時候,首先需要將ChangeNotifierProvider插入頂層

void main() {
  runApp(
    ChangeNotifierProvider(
        create:(context)=>UserInfoProvider(),
        child: MaterialApp(
           home: MyAppScaffold()
        )
    )
  );
}

Ⅲ在需要的地方透過Provider或Consumer或Selector使用共享的資料
Provider使用比較簡單,直接透過of方法就能拿到資料,不存在巢狀子控制元件的問題;
Consumer和Selector則需要包裹用到資料的子控制元件,但好處是資料更改時,只會區域性重新整理,而Provider因為沒有巢狀,不知道哪些地方用到了資料,所以會全部重新整理。而Selector相對於Consumer更重要的一點是,Selector能夠控制是否需要重建重新整理。所以在某些情況下,使用Selector來代替Consumer,效能會更高。

Consumer的builder方法解析:
  引數一:context,每個build方法都會有上下文,目的是知道當前樹的位置
  引數二:ChangeNotifier對應的例項,也是我們在builder函式中主要使用的物件
  引數三:child,目的是進行最佳化,如果builder下面有一顆龐大的子樹,當模型發生改變的時候,我們並不希望重新build這顆子樹,那麼就可以將這顆子樹放到Consumer的child中,在這裡直接引入即可(注意我案例中的Icon所放的位置)

Selector和Consumer對比,不同之處主要是三個關鍵點:
  關鍵點1:泛型引數是兩個
    泛型引數一:我們這次要使用的Provider
    泛型引數二:轉換之後的資料型別,比如我這裡轉換之後依然是使用CounterProvider,那麼他們兩個就是一樣的型別
  關鍵點2:selector回撥函式
    轉換的回撥函式,你希望如何進行轉換
    S Function(BuildContext, A) selector
    我這裡沒有進行轉換,所以直接將A例項返回即可
  關鍵點3:是否希望重新rebuild
    這裡也是一個回撥函式,我們可以拿到轉換前後的兩個例項;
    bool Function(T previous, T next);
    因為這裡我不希望它重新rebuild,無論資料如何變化,所以這裡我直接return false;

                Container(
                  width: 200,

                  child:
                  ③Consumer是否是最好的選擇呢?並不是。比如這裡,這裡的操作只是修改共享資料,所以並不需要重新Builder,所以使用Selector來代替Consumer,從而選擇要不要重新構建
                  Selector<UserInfoProvider, UserInfoProvider>(//這裡的泛型就是引數provider的型別,明確型別方便我們訪問provider的屬性
                      selector: (ctx, provider) => provider,
                      shouldRebuild: (pre, next) => false,//是否希望重新rebuild
                      builder: (ctx, provider, child){

                          print("EditUserInfoPage---Consumer的builder方法");
                          return ElevatedButton(
                            child: Text("儲存",style: TextStyle(fontSize: 20,color: Colors.white)),
                            onPressed: (){
                              if(_formKey.currentState!.validate()){//手動呼叫Form的State物件的validate方法
                                // 如果表單驗證透過,則執行相關操作
                                _formKey.currentState!.save();//執行save方法,儲存表單中的資料
                                print("使用者名稱稱是:$userName,年齡是:$age");

                                // 修改資料
                                provider.uinfo = UserInfo(userName, int.parse(age));

                                Navigator.pop(context);
                              }
                            },
                          );
                        }
                    ),

                   ②Consumer(相對推薦): 當Provider中的資料發生改變時, 執行重新執行Consumer的builder;
                   Consumer<UserInfoProvider>( //這裡的泛型就是引數provider的型別,明確型別方便我們訪問provider的屬性
                       builder: (ctx, provider, child){
                  
                         print("EditUserInfoPage-- Consumer的builder方法");
                         return ElevatedButton(
                           child: Text("儲存",style: TextStyle(fontSize: 20,color: Colors.white)),
                           onPressed: (){
                             if(_formKey.currentState!.validate()){//手動呼叫Form的State物件的validate方法
                               // 如果表單驗證透過,則執行相關操作
                               _formKey.currentState!.save();//執行save方法,儲存表單中的資料
                               print("使用者名稱稱是:$userName,年齡是:$age");

                               // 修改資料
                               provider.uinfo = UserInfo(userName, int.parse(age));
                               Navigator.pop(context);
                             }
                           },
                         );
                       }
                   ),


                   ①Provider.of: 當Provider中的資料發生改變時, Provider.of所在的Widget整個build方法都會重新構建;
                   ElevatedButton(
                     child: Text("儲存",style: TextStyle(fontSize: 20,color: Colors.white)),
                     onPressed: (){
                       if(_formKey.currentState!.validate()){//手動呼叫Form的State物件的validate方法
                         // 如果表單驗證透過,則執行相關操作
                         _formKey.currentState!.save();//執行save方法,儲存表單中的資料
                         print("使用者名稱稱是:$userName,年齡是:$age");
                         
                         // 修改資料:
                         Provider.of<UserInfoProvider>(context,listen: false).uinfo = UserInfo(userName, int.parse(age));
                  
                         Navigator.pop(context);
                       }
                     },
                   ),
                )

  上面我們提到的都是一個Provider的情況,但實際開發中可能存在需要同時獲取多個共享資料的情況。我們可以透過將這些資料都放在同一個被觀察者物件(ChangeNotifier)裡去實現該功能,但有時候這些資料並沒有關聯,如果巢狀層級過多不方便維護,擴充套件性也比較差。所以需要分開存放,比如一個頁面需要用到使用者資訊和商品資訊,總不能都放在一個被觀察者裡。這個時候就用到了MultiProvider和
Consumer2/Consumer3/Consumer4/Consumer5/Consumer6以及Selector2/Selector3/Selector4/Selector5/Selector6。

runApp(MultiProvider(
  providers: [
    ChangeNotifierProvider(create: (ctx) => CounterProvider()),
    ChangeNotifierProvider(create: (ctx) => UserProvider()),
  ],
  child: MyApp(),
));

class HYShowData03 extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer2<UserProvider, CounterProvider>(
      builder: (ctx, userVM, counterVM, child) {
        return Text(
          "nickname:${userVM.user.nickname} counter:${counterVM.counter}",
          style: TextStyle(fontSize: 30),
        );
      },
    );
  }
}

16、事件監聽、手勢識別Gesture
在Flutter中,手勢有兩個不同的層次:
  第一層:原始指標事件(Pointer Events):描述了螢幕上由觸控板、滑鼠、指示筆等觸發的指標的位置和移動。一共有四種指標事件:

  • PointerDownEvent 指標在特定位置與螢幕接觸
  • PointerMoveEvent 指標從螢幕的一個位置移動到另外一個位置
  • PointerUpEvent 指標與螢幕停止接觸
  • PointerCancelEvent 指標因為一些特殊情況被取消
class HomeContent extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Center(
      child: Listener(
        child: Container(
          width: 200,
          height: 200,
          color: Colors.red,
        ),
        onPointerDown: (event) => print("手指按下:$event"),
        onPointerMove: (event) => print("手指移動:$event"),
        onPointerUp: (event) => print("手指抬起:$event"),
      ),
    );
  }
}

  第二層:手勢識別(Gesture Detector):這個是在原始事件上的一種封裝。Gesture分層非常多的種類:

  • 點選:

    onTapDown:使用者發生手指按下的操作
    onTapUp:使用者發生手指抬起的操作
    onTap:使用者點選事件完成
    onTapCancel:事件按下過程中被取消

  • 雙擊:

    onDoubleTap:快速點選了兩次

  • 長按:

    onLongPress:在螢幕上保持了一段時間

  • 縱向拖拽:

    onVerticalDragStart:指標和螢幕產生接觸並可能開始縱向移動;
    onVerticalDragUpdate:指標和螢幕產生接觸,在縱向上發生移動並保持移動;
    onVerticalDragEnd:指標和螢幕產生接觸結束;

  • 橫線拖拽:

    onHorizontalDragStart:指標和螢幕產生接觸並可能開始橫向移動;
    onHorizontalDragUpdate:指標和螢幕產生接觸,在橫向上發生移動並保持移動;
    onHorizontalDragEnd:指標和螢幕產生接觸結束;

  • 移動:

    onPanStart:指標和螢幕產生接觸並可能開始橫向移動或者縱向移動。如果設定了 onHorizontalDragStart 或者 onVerticalDragStart,該回撥方法會引發崩潰;
    onPanUpdate:指標和螢幕產生接觸,在橫向或者縱向上發生移動並保持移動。如果設定了 onHorizontalDragUpdate 或者 onVerticalDragUpdate,該回撥方法會引發崩潰。
    onPanEnd:指標先前和螢幕產生了接觸,並且以特定速度移動,此後不再在螢幕接觸上發生移動。如果設定了 onHorizontalDragEnd 或者 onVerticalDragEnd,該回撥方法會引發崩潰。

  但有時候,我們也需要某個元件不響應事件,比如Cell上的按鈕。這個時候就用到了AbsorbPointerIgnorePointer。這兩個元件都可以做到隔離事件,但也有區別。IgnorePointer的child不響應事件,但是事件會傳遞到下一層;而AbsorbPointer 設為不響應事件時(即absorbing = true),事件會被吞噬,不會透傳到下一層。

class MyApp5 extends StatelessWidget{
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: Stack(
            alignment: Alignment.center,
            children: [
              GestureDetector(
                child: Container(
                    color: Colors.red,
                    height: 200,
                    width: 200
                ),
                onTap: (){
                  print("點選紅色區域");
                },
              ),

              GestureDetector(
                child: Container(
                    color: Colors.blue,
                    height: 100,
                    width: 100,
                ),
                onTap: (){
                  print("點選藍色區域");
                },
              ),

              IgnorePointer(//IgnorePointer的child不響應事件,但是事件會傳遞到下一層;所以點選黃色區域列印的是“點選藍色區域”
              child: GestureDetector(
                    child: Container(
                      color: Colors.yellow,
                      height: 50,
                      width: 50,
                    ),
                    onTap: () {
                      print("點選黃色區域");
                    }),
                ignoring: true,
              ),

              AbsorbPointer(//AbsorbPointer的child不響應事件,事件會被吞噬,不會透傳到下一層。所以點選黑色區域沒有反應
              child:GestureDetector(
                    child: Container(
                      color: Colors.black,
                      height: 30,
                      width: 30,
                    ),
                    onTap: () {
                      print("點選黑色區域");
                    }),
                absorbing: true,
              )
            ],
          ),
        ),
      ),
    );
  }
}

17、路由導航
  路由主要是用於頁面跳轉的一種方式,方便管理頁面之間的跳轉和互相傳遞資料,進行互動,通常也可被稱為導航管理。Flutter 中的路由管理和原生開發類似,無論是 Android 還是 iOS,導航管理都會維護一個路由棧,路由入棧(push)操作對應開啟一個新頁面,路由出棧(pop)操作對應頁面關閉操作,而路由管理主要是指如何來管理路由棧。
  Navigator是一個路由管理的元件,它提供了開啟和退出路由頁方法。Navigator透過一個棧來管理活動路由集合。我們開發中並不需要手動去建立一個Navigator,因為使用的MaterialApp、CupertinoApp、WidgetsApp它們預設是有插入Navigator的,所以直接使用即可:Navigator.of(context)

  Flutter 中給我們提供了兩種配置路由跳轉的方式:1、普通路由 2、命名路由。

  • 普通路由:使用比較簡單,不需要註冊路由表,也不需要設定初始路由。透過push方法,直接指定要跳轉的頁面路由的方式,而不使用路由名稱。
    Future<T> push<T extendsObject>(Route<T> route)

  push方法需要傳入一個路由物件,但是Route是一個抽象類,所以它是不能例項化的,我們一般使用Material元件庫提供的元件MaterialPageRoute。MaterialPageRoute在不同的平臺有不同的表現,對Android,開啟一個頁面會從螢幕底部滑動到頂部,關閉頁面時從頂部滑動到底部消失;對iOS,開啟一個頁面會從螢幕右側滑動到左側,關閉頁面時從左側滑動到右側消失。構造方法如下:

         MaterialPageRoute({
            WidgetBuilder builder, //構建路由頁面的具體內容,返回值是一個widget。我們通常要實現此回撥,返回新路由的例項。
            RouteSettings settings, //settings 包含路由的配置資訊,如路由名稱、是否初始路由(首頁)。
            bool maintainState = true, //已經不可見(被上面的蓋住完全看不到)的元件,是否還需要儲存狀態。maintainState設定為true是昂貴的,但如果為false,那當不可見時,對應的頁面會被銷掉,如果pop回去時重新建立的,那上個頁面的狀態就無法保持了。這可能也是MaterialPageRoute預設maintainState為true的原因。
            bool fullscreenDialog = false, //新的路由頁面是否是一個全屏的模態對話方塊,在 iOS 中,如果fullscreenDialog為true,新頁面將會從螢幕底部滑入(而不是水平方向)【present
         })

如果在 Android 上也想使用iOS的左右切換風格,可以使用 CupertinoPageRoute。當然,iOS平臺我們也可以使用CupertinoPageRoute。

              //①透過以下方法實現跳轉
              Navigator.of(context).push(MaterialPageRoute(builder: (context) {
                  return const SearchPage(); //直接返回要跳轉到的頁面
              }));

             //②返回上一級路由
             Navigator.of(context).pop();

            //③當然也可以透過路由傳遞引數:
            class SearchPage extends StatelessWidget {
               final String title;
               //建構函式中新增引數,用於接收從上一個頁面傳遞過來的資料。
               const SearchPage({super.key, this.title = "Search"});
            }

            Navigator.of(context).push(MaterialPageRoute(builder: (context) {
                // return const SearchPage();
                return const SearchPage(title: "搜尋vvvvv",);  //透過建構函式傳值
              }));

            //④pop返回的時候也可以攜帶引數到上級頁面
                Navigator.of(context).pop("a detail message");
             但這個資料如何拿到呢?
             在頁面push跳轉時,會返回一個Future。該Future會在詳情頁面呼叫pop時,回撥對應的then函式,並且會攜帶結果    
            // 1.跳轉程式碼
            final future = Navigator.of(context).push(MaterialPageRoute(
                     builder: (ctx) {
                          return DetailPage("a home message");
                     }
           ));

            // 2.獲取結果
           future.then((res) {
                _message = res;
            });
 
  • 命名路由:我們可以透過建立一個新的Route,使用Navigator來導航到一個新的頁面,但是如果在應用中很多地方都需要導航到同一個頁面(比如在開發中,首頁、推薦、分類頁都可能會跳到詳情頁),那麼就會存在很多重複的程式碼。在這種情況下,我們可以使用命名路由(named route)

  命名路由是一種將路由(頁面)和名稱關聯對映起來的方式,在一個地方進行統一的管理。可以呼叫Navigator 的pushNamed方法,透過名稱來導航到相應的頁面。使用命名路由時,需要在應用程式的路由表中註冊路由名稱和對應的頁面元件。在透過路由名字開啟新路由時,應用會根據路由名字在路由表中查詢到對應的WidgetBuilder回撥函式,然後呼叫該回撥函式生成路由widget並返回。

Future pushNamed(BuildContext context, String routeName,{Object arguments})

  相對於普通路由的拿起來就用,命名路由則多了一個步驟,需要設定管理(註冊路由、設定初始路由),一般放到MaterialApp的 initialRouteroutes 中管理。
    initialRoute:設定應用程式從哪一個路由開始啟動,設定了該屬性,就不需要再設定home屬性了
    routes:定義名稱和路由之間的對映關係,型別為Map<String, WidgetBuilder>。key為路由的名字,是個字串;value是個builder回撥函式,用於生成相應的路由widget。

        return MaterialApp(
            title: 'Flutter Demo',
            theme: ThemeData(
              primarySwatch: Colors.blue, splashColor: Colors.transparent
            ),
            initialRoute: "/",
            routes: {
              "/home": (ctx) => HomePage(),
              "/detail": (ctx) => ProductionDetailPage()
            },
        );

  使用的話也簡潔許多:

Navigator.of(context).pushNamed("/detail"); //直接跳轉到ProductionDetailPage頁面

  透過pushNamed函式的定義就可以知道,我們可以透過該方法的arguments引數直接傳遞引數,並不需要透過目標頁面的建構函式傳值:

Navigator.of(context).pushNamed("/shopDetailView",arguments: _model);

  在目標頁面中獲取引數:

_model = ModalRoute.of(context)?.settings.arguments as DataBean;

  因為pushNamed方法返回值也是一個Future引數,所以如果pop攜帶引數的話,同樣可以解析拿到

  • 返回按鈕的監聽

  這裡有一個問,點選返回按鈕預設是直接執行pop方法的,但預設是不會攜帶任何引數的。所以如果使用者是點選右上角的返回按鈕,如何監聽呢?
    方法一:自定義返回的按鈕(在詳情頁中修改Scaffold的appBar)

            appBar: AppBar(
                title: Text("詳情頁"),
                leading: IconButton(
                    icon: Icon(Icons.arrow_back),
                    onPressed: () {
                       Navigator.of(context).pop("a back detail message");
                    },
                 ),
              )

    方法二:監聽返回按鈕的點選(給Scaffold(頁面)包裹一個WillPopScope)
    WillPopScope有一個onWillPop的回撥函式,當我們點選返回按鈕時會執行。這個函式要求有一個Future的返回值:
      ·true:那麼系統會自動幫我們執行pop操作
      ·false:系統不再執行pop操作,需要我們自己

                   return WillPopScope(
                        onWillPop: () {
                            Navigator.of(context).pop("a back detail message");
                            return Future.value(false);
                         },
                       child: Scaffold(
                           appBar: AppBar(title: Text("詳情頁"),),
                           body: ·······
                        );
                    );
  • 鉤子函式onGenerateRoute、onUnknownRoute

  命名路由雖然使用方便,但是路由表裡的頁面都是預設建構函式建立的,無法在初始化的時候傳參。這個時候我們可以在註冊路由表時透過設定onGenerateRoute函式來進行個性化設定。當透過名稱無法在路由表中找到對應的頁面是,會首先來到這裡檢視是否做了特殊處理。所以我們可以在該函式中,手動建立對應的Route進行返回;
  該函式有一個引數RouteSettings,該類有兩個常用的屬性:
    name: 跳轉的路徑名稱
    arguments:跳轉時攜帶的引數

  如果在onGenerateRoute這裡也沒找到對應名稱的路由跳轉處理的話,就會來到另一個鉤子函式onUnknownRoute。也就是如果我們開啟的一個路由名稱在路由表裡不存在也沒有在onGenerateRoute做特殊處理的話,就會來到onGenerateRoute。預設是沒有實現的,所以如果跳轉到了不存在的路由器時,Flutter就會報錯。所以為了避免報錯,我們可以建立一個錯誤提示的頁面,讓所有不存在的跳轉都跳轉到錯誤提示頁面的路由。

     //測試路由的鉤子函式建立的類
     class AboutPage extends StatelessWidget{

         AboutPage(value){//自定義建構函式
            print(value);
         }

        @override
        Widget build(BuildContext context) {
            return Container();
        }
     }

    ////路由異常跳轉錯誤提示頁面
     class UnknownPage extends StatelessWidget {
         @override
         Widget build(BuildContext context) {
             return Scaffold(
                appBar: AppBar(
                   title: Text("錯誤頁面"),
                ),
               body: Container(
                   child: Center(
                       child: Text("頁面跳轉錯誤"),
                   ),
              ),
           );
        }
     }

     child: MaterialApp(
        //初始化主路由
          initialRoute:"/",
          routes: {"/editUserInfoPage":(BuildContext context) => EditUserInfoPage()},
          title: 'Flutter Demo',
          onGenerateRoute: (settings){ //pushNamed對應的name沒有在routes中有對映關係,那麼就會執行onGenerateRoute鉤子函式;
            /*
            * 函式有一個引數RouteSettings,該類有兩個常用的屬性:
            * name: 跳轉的路徑名稱
            * arguments:跳轉時攜帶的引數
            * */
            if (settings.name == "/about") {  //在該函式中,對某個路由名稱手動建立對應的Route進行返回;
              return MaterialPageRoute(
                  builder: (ctx) {
                    return AboutPage(settings.arguments);//可以傳參初始化
                  }
              );
            }
            return null;
          },

          onUnknownRoute: (settings){ //路由名稱不存在,先去會執行onGenerateRoute鉤子函式看有沒有對該名稱做出處理,沒有的話則來到這個鉤子函式。我們一般在這裡做出處理,跳轉到一個統一的錯誤頁面。
            return MaterialPageRoute(
                builder: (ctx) {
                  return UnknownPage();
                }
            );
          },
    );

  在使用路由(Navigator),有時候會報下述錯誤:Navigator operation requested with a context that does not include a Navigator.

void main() {
   runApp(const MyApp3());
}
class MyApp3 extends StatelessWidget{
  @override
  Widget build(BuildContext context) {
      return MaterialApp(
          //初始化主路由
          initialRoute:"/",
          routes: {"/editUserInfoPage":(BuildContext context) => EditUserInfoPage()},
          home:Scaffold(
              appBar: AppBar( ),
            floatingActionButton: FloatingActionButton(
                onPressed: (){
                  Navigator.pushNamed(context, "/editUserInfoPage");
                }),
          ),
        );
    }
}

這是由於 Navigator 的查詢機制導致的錯誤,
  Navigator 查詢機制 : 這是由於呼叫了 Navigator.of(context) 程式碼獲取 Navigator , 注意這裡的 context 上下文關聯的是 StatelessWidget 元件 , 也就是從該 StatelessWidget 元件開始 , 向上查詢 Navigator ;

  但是實際的層級是這樣的 , StatelessWidget 包裹 MaterialApp 包裹 Scaffold 包裹 floatingActionButton, 查詢 Navigator 時 , 到了MaterialApp後,發現還有上層,所以越過了 MaterialApp , 直接從最頂層的 StatelessWidget 元件開始向上查詢 , 肯定找不到 Navigator , 這裡直接報錯了 ;

  這是 , 解決這個問題也很簡單 , 在 StatelessWidget 的外層再包裹一個 MaterialApp , 這樣就可以解決問題了 , 這樣從 StatelessWidget 元件開始向上查詢 Navigator , 就可以找到 Navigator , 問題解決 。

void main() {
   runApp(
       MaterialApp(
        //初始化主路由
          initialRoute:"/",
          routes: {"/editUserInfoPage":(BuildContext context) => EditUserInfoPage()},
          home: MyAppScaffold()
   );
}

class MyAppScaffold  extends StatelessWidget{
  @override
  Widget build(BuildContext context) {
      return Scaffold(
              appBar: AppBar( ),
              floatingActionButton: FloatingActionButton(
              onPressed: (){
                  Navigator.pushNamed(context, "/editUserInfoPage");
              }),
       );
    }
}

18、主題 Theme引數詳解詳見https://www.jianshu.com/p/d9b486074485

  • 全域性主題

   全域性Theme會影響整個app的顏色和字型樣式。使用起來非常簡單,只需要向MaterialApp構造器傳入 ThemeData 即可。如果沒有設定Theme,Flutter將會使用預設的預設樣式。

        factory ThemeData({
           Brightness brightness, // 應用主題亮度,可選(dark、light)
           VisualDensity visualDensity, // 視覺密度
           MaterialColor primarySwatch, // 主要樣式,設定primaryColor後該背景色會被覆蓋
           Color primaryColor, // 主要部分背景顏色(導航和tabBar等)
           Brightness primaryColorBrightness, // primaryColor的亮度
           Color primaryColorLight, // primaryColor的淺色版
           Color primaryColorDark, // primaryColor的深色版
           Color accentColor, // 前景色(文字,按鈕等)
           Brightness accentColorBrightness, // accentColor的亮度
           Color canvasColor, // MaterialType.canvas 的預設顏色
           Color shadowColor, // 陰影顏色
           Color scaffoldBackgroundColor, // Scaffold的背景顏色。典型Material應用程式或應用程式內頁面的背景顏色
           Color bottomAppBarColor, // BottomAppBar的預設顏色
           Color cardColor, // Card的顏色
           Color dividerColor, // Divider和PopupMenuDivider的顏色,也用於ListTile之間、DataTable的行之間等。
           Color focusColor, // 焦點顏色
           Color hoverColor, // hoverColor
           Color highlightColor, // 高亮顏色,選中在潑墨動畫期間使用的突出顯示顏色,或用於指示選單中的項。
           Color splashColor, // 墨水飛濺的顏色。InkWell
           InteractiveInkFeatureFactory splashFactory, // 定義由InkWell和InkResponse反應產生的墨濺的外觀。
           Color selectedRowColor, // 用於突出顯示選定行的顏色。
           Color unselectedWidgetColor, // 用於處於非活動(但已啟用)狀態的小部件的顏色。例如,未選中的核取方塊。通常與accentColor形成對比。也看到disabledColor。
           Color disabledColor, // 禁用狀態下部件的顏色,無論其當前狀態如何。例如,一個禁用的核取方塊(可以選中或未選中)。
           Color buttonColor, // RaisedButton按鈕中使用的Material 的預設填充顏色。
           ButtonThemeData buttonTheme, // 定義按鈕部件的預設配置,
           ToggleButtonsThemeData toggleButtonsTheme, // 切換按鈕的主題
           Color secondaryHeaderColor, // 選定行時PaginatedDataTable標題的顏色。
           Color textSelectionColor, // 文字框中文字選擇的顏色,如TextField
           Color cursorColor, // 文字框中游標的顏色,如TextField
           Color textSelectionHandleColor, // 調整當前選定的文字部分的控制代碼的顏色。
           Color backgroundColor, // 與主色形成對比的顏色,例如用作進度條的剩餘部分。
           Color dialogBackgroundColor, // Dialog元素的背景顏色
           Color indicatorColor, // 選項卡中選定的選項卡指示器的顏色。
           Color hintColor, // 用於提示文字或佔位符文字的顏色,例如在TextField中。
           Color errorColor, // 用於輸入驗證錯誤的顏色,例如在TextField中
           Color toggleableActiveColor, // 用於突出顯示Switch、Radio和Checkbox等可切換小部件的活動狀態的顏色。
           String fontFamily, // 文字字型
           TextTheme textTheme, // 文字的顏色、字號。
           TextTheme primaryTextTheme, // 與primaryColor形成對比的文字主題
           TextTheme accentTextTheme, // 與accentColor形成對比的文字主題。
           InputDecorationTheme inputDecorationTheme, // 基於這個主題的 InputDecorator、TextField和TextFormField的預設InputDecoration值。
           TabBarTheme tabBarTheme, // 用於自定義選項卡欄指示器的大小、形狀和顏色的主題。
           TooltipThemeData tooltipTheme, // tooltip主題
           CardTheme cardTheme, // Card的顏色和樣式
           AppBarTheme appBarTheme, // appBar主題
           ColorScheme colorScheme, // 擁有13種顏色,可用於配置大多陣列件的顏色。
           NavigationRailThemeData navigationRailTheme, // 導航邊欄主題
           // ...
       })

  我們可以看到, 主題裡有太多的屬性, 實際應用中, 我們不需要全部定製, 我們只需要把握關鍵的幾個屬性就可以為整個APP定調, 如果需要進一步個性化定製 再去研究更細的屬性, 或者重新寫屬於自己的控制元件也可以。而且需要注意的是上面的屬性是舊版的,所以有些屬性以及過期或者不在支援了。比如primaryColor(設定無效果)和accentColor(不再支援)已經失效了,但是可以透過colorScheme屬性中的primary和secondary來去進行設定

class MyApp6 extends StatelessWidget{
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
          // 1.整體明暗對比度: light-dark,設為light時,整體讀取的是primary和onPrimary的顏色,設為dark時讀取的是surface和onsurface的顏色
          brightness: Brightness.light,

          // 2.primarySwatch: primaryColor/accentColor的結合體,用於設定主要顏色:導航、tabBar、floatingActionButton等
          primarySwatch: Colors.red,

        colorScheme: ColorScheme(
            brightness: Brightness.light,//外觀風格,需要與themeData中的brightness一樣,否則會報錯
            primary: Colors.brown,//同primaryColor,主要部分背景顏色(導航和tabBar等)
            onPrimary: Colors.green, //此顏色用於為原色之上的元素(例如文字、圖示等)著色。
            secondary: Colors.pink,//同accentColor,前景色,用於UI中不太突出的元件的強調色,例如(按鈕、文字、覆蓋邊緣效果等)
            onSecondary: Colors.purple,//該顏色用於為次要顏色上的元素著色。
            error: Colors.white,//用於輸入驗證錯誤的顏色,例如[InputDecoration.errorText]
            onError: Colors.yellow,//這是與 error 顏色相得益彰的文字顏色,例如紅色標誌上的白色文字,便於閱讀。
            background: Colors.teal,//整個應用程式的主要背景色。將其視為放置所有其他 UI 元素的畫布。
            onBackground: Colors.indigoAccent,//顏色用於為背景色上的元素著色。
            surface: Colors.orangeAccent,//影響元件表面的表面顏色,例如卡片、表格和選單。
            onSurface: Colors.blue // 在表面顏色上顯示的用於文字和圖示的顏色。
        ),

          // 5.卡片主題
          cardTheme: CardTheme(
              color: Colors.greenAccent,
              elevation: 10,
              shape: Border.all(width: 3, color: Colors.red),
              margin: EdgeInsets.all(10)
          ),

          // 6.按鈕主題
          buttonTheme: ButtonThemeData(
              minWidth: 0,
              height: 25
          ),

          // 7.文字主題
          textTheme: TextTheme(//Material3將文字元素按用途分為5種:display、headline、title、label、body。每種又分為small、medium、large三種尺寸。因此一共支援15種不同大小的字型。
            bodyMedium: TextStyle(fontSize: 30, color: Colors.blue),//body中文字主題
            titleLarge: TextStyle(fontSize: 20,color: Colors.white),//標題中文字主題
          )
      ),

      home: Scaffold(
        appBar: AppBar(title: Text("主題測試")),
        bottomNavigationBar: BottomNavigationBar(items: [
          BottomNavigationBarItem(icon: Icon(Icons.sailing),label: "first"),
          BottomNavigationBarItem(icon: Icon(Icons.save),label: "second"),
        ]),
        body: Container(
          color: Colors.white,
          child: Center(
            child: Column(
              children: [
                Text("我是正文"),
                TextButton(onPressed: (){}, child: Text("按鈕"))
              ],
            )
          ),
        ),
        floatingActionButton: FloatingActionButton(
          child: Icon(Icons.bluetooth_connected_sharp),
          onPressed: (){},
        ),
      ),
    );
  }
}
  • 區域性主題

  如果某個具體的Widget不希望直接使用全域性的Theme,而希望自己來定義,應該如何做呢?
    非常簡單,只需要在該Widget的父節點包裹一下Theme即可,這樣建立出來新的頁面,會使用新的主題:

      class SecondPage extends StatelessWidget {//在新的頁面的Scaffold外,包裹了一個Theme,並且設定data為一個新的ThemeData
          @override
          Widget build(BuildContext context) {
              return Theme(
                 data: ThemeData(primarySwatch: Colors.orange),
                 child: Scaffold()
             );
          }
      }

    但是,我們很多時候並不是想完全使用一個新的主題,也可以在之前的主題基礎之上進行修改:

      class HYSecondPage extends StatelessWidget {
           @override
           Widget build(BuildContext context) {
               return Theme(
                 data: Theme.of(context).copyWith(//它只會覆蓋掉設定的這些主題屬性,其他未修改的則仍然是之前設定好的樣式
                     colorScheme: ColorScheme(brightness:Theme.of(context).brightness, primary: Colors.green,······· ),

                child: Scaffold(),
              );
           }
      }
  • 暗黑適配

  目前很多應用程式都需要適配暗黑模式,Flutter中如何做到暗黑模式的適配呢?
  事實上,MaterialApp中有theme和dartTheme兩個引數,如果只設定theme的話,則沒有適配暗黑模式,即白天晚上一個樣式。如果兩個都設定的話,那麼就進行了暗黑模式的適配,自動根據系統的設定選擇對應的主題。

  需要注意的一點是ThemeData中有個屬性brightness,他有兩個值: Brightness.light和 Brightness.dark,看到值可能以為這個是設定暗黑模式的,其實並不是。這個屬性和適配暗黑模式沒有關係,是設定整體明暗對比度的。設為light時,整體讀取的是primary和onPrimary的顏色,設為dark時讀取的是surface和onsurface的顏色。適配暗黑模式只能透過實現darkTheme和theme來實現

   class MyApp extends StatelessWidget {
          // This widget is the root of your application.
          @override
          Widget build(BuildContext context) {
              return MaterialApp(
                 title: 'Flutter Demo',
                 theme: ThemeData.light(),
                 darkTheme: ThemeData.dark(),
                 home: HomePage(),
               );
           }
       }

  在開發中,為了能適配兩種主題(設定是更多的主題),我們可以封裝一個AppTheme,封裝一個亮色主題,封裝一個暗黑主題,將公共的樣式抽取成常量。

class AppTheme {
  // 1.抽取相同的樣式
  staticconstdouble _titleFontSize = 20;
  
  // 2.亮色主題
  staticfinal ThemeData lightTheme = ThemeData(
    primarySwatch: Colors.pink,
    primaryTextTheme: TextTheme(
      title: TextStyle(
        color: Colors.yellow,
        fontSize: _titleFontSize
      )
    ),
    textTheme: TextTheme(
      body1: TextStyle(color: Colors.red)
    )
  );
  
  // 3.暗黑主題
  staticfinal ThemeData darkTheme = ThemeData(
    primaryColor: Colors.grey,
    primaryTextTheme: TextTheme(
      title: TextStyle(
        color: Colors.white,
        fontSize: _titleFontSize
      )
    ),
    textTheme: TextTheme(
      title: TextStyle(color: Colors.white),
      body1: TextStyle(color: Colors.white70)
    )
  );
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: AppTheme.lightTheme,
      darkTheme: AppTheme.darkTheme,
      home: HomePage(),
    );
  }
}

----------------------------------------------------ThemeData引數詳解參考----------------------------------------------------
  一個顏色方案包含了所有為 MaterialTheme 命名的顏色引數。顏色方案旨在和諧一致,確保可訪問的文字,並將 UI 元素和表面彼此區分開。有兩個內建的基線方案,即 lightColorSchemedarkColorScheme,可以按原樣使用或進行自定義。
  材料顏色系統和自定義方案為顏色提供了預設值,作為自定義的起點。

屬性:
primary - 主要顏色是在您的應用程式的螢幕和元件上最頻繁顯示的顏色。
onPrimary - 在主要顏色上顯示的用於文字和圖示的顏色。
primaryContainer - 容器的首選色調顏色。
onPrimaryContainer - 應在 primaryContainer 頂部使用的內容的顏色(和狀態變體)。
inversePrimary- 在需要反轉顏色方案的地方用作“主要”顏色,例如 Snackbar 上的按鈕。

secondary -次要顏色為您的產品提供了更多的強調和區分方式。次要顏色最適合:浮動操作按鈕、選擇控制元件,如核取方塊和單選按鈕、突出顯示選定的文字、連結和標題
onSecondary - 在次要顏色上顯示的用於文字和圖示的顏色。
secondaryContainer - 用於容器的色調顏色。
onSecondaryContainer - 應在 secondaryContainer 頂部使用的內容的顏色(和狀態變體)。
tertiary - 可以用來平衡主要和次要顏色,或者對諸如輸入欄位等元素給予更高的關注。
onTertiary - 在三級顏色上顯示的用於文字和圖示的顏色。
tertiaryContainer - 用於容器的色調顏色。
onTertiaryContainer - 應在 tertiaryContainer 頂部使用的內容的顏色(和狀態變體)。
background - 出現在可滾動內容後面的背景顏色。
onBackground - 在背景顏色上顯示的用於文字和圖示的顏色。
surface - 影響元件表面的表面顏色,例如卡片、表格和選單。
onSurface - 在表面顏色上顯示的用於文字和圖示的顏色。
surfaceVariant - 具有與表面類似用途的另一種顏色選項。
onSurfaceVariant - 可用於表面上方內容的顏色(和狀態變體)。
surfaceTint - 此顏色將由應用色調提升的元件使用,並在表面之上應用。海拔越高,使用這種顏色就越多。
inverseSurface - 與表面形成鮮明對比的顏色。對於位於其他具有表面顏色的表面之上的表面非常有用。
inverseOnSurface - 與 inverseSurface 形成良好對比的顏色。對於位於具有 inverseSurface 的容器之上的內容很有用。
error - 用於指示元件中的錯誤,例如文字欄位中的無效文字的錯誤顏色。
onError - 在錯誤顏色上顯示的用於文字和圖示的顏色。
errorContainer - 錯誤容器的首選色調顏色。
onErrorContainer - 應在 errorContainer 頂部使用的內容的顏色(和狀態變體)。
outline - 用於邊界的微妙顏色。輪廓顏色角色為可訪問性目的增加了對比度。
outlineVariant - 在不需要強烈對比度時用於邊界的實用顏色,用於裝飾元素的邊界。
scrim - 遮擋內容的蒙板顏色。
surfaceBright - 始終比表面更亮的表面變體,無論是在亮模式還是暗模式下。
surfaceDim - 始終比表面更暗的表面變體,無論是在亮模式還是暗模式下。
surfaceContainer - 影響元件容器的表面變體,例如卡片、表格和選單。
surfaceContainerHigh - 比 surfaceContainer 具有更高強調的容器的表面變體。將此角色用於需要比 surfaceContainer 更強調的內容。
surfaceContainerHighest - 比 surfaceContainerHigh 具有更高強調的容器的表面變體。將此角色用於需要比 surfaceContainerHigh 更強調的內容。
surfaceContainerLow - 比 surfaceContainer 強調程度低的容器的表面變體。將此角色用於需要比 surfaceContainer 更少強調的內容。
surfaceContainerLowest - 比 surfaceContainerLow 強調程度低的容器的表面變體。將此角色用於需要比 surfaceContainerLow 更少強調的內容。

ColorScheme引數解釋

class ColorScheme(
/* 主色系 */
primary: Color, // 主色,用於應用的大部分 UI 元素,如按鈕、選中的選項卡等。
onPrimary: Color, // 在主色上清晰顯示的顏色,通常用於文字或圖示。
primaryContainer: Color, // 主色的容器色,用於需要主色變體的元素背景。
onPrimaryContainer: Color, // 在主色容器上清晰顯示的顏色,通常用於文字或圖示。
inversePrimary: Color, // 主色的反色,用於在對比背景上需要主色時。

/* 次色系 */
secondary: Color, // 次級色,用於補充主色或用作次要的 UI 元素。
onSecondary: Color, // 在次級色上清晰顯示的顏色,通常用於文字或圖示。
secondaryContainer: Color, // 次級色的容器色,用於需要次級色變體的元素背景。
onSecondaryContainer: Color, // 在次級色容器上清晰顯示的顏色,通常用於文字或圖示。

/* 第三色系 */
tertiary: Color, // 第三色,用於需要注意或區分的 UI 元素。
onTertiary: Color, // 在第三色上清晰顯示的顏色,通常用於文字或圖示。
tertiaryContainer: Color, // 第三色的容器色,用於背景或填充色。
onTertiaryContainer: Color, // 在第三色容器上清晰顯示的顏色,通常用於文字或圖示。

/* 背景與表面色 */
background: Color, // 背景色,用於頁面或元件的背景。
onBackground: Color, // 在背景色上清晰顯示的顏色,通常用於文字或圖示。
surface: Color, // 表面色,用於卡片、選單和其他元素的背景。
onSurface: Color, // 在表面色上清晰顯示的顏色,通常用於文字或圖示。
surfaceVariant: Color, // 表面色的變體,用於需要區分的表面元素。
onSurfaceVariant: Color, // 在表面色變體上清晰顯示的顏色。
surfaceTint: Color, // 表面色的著色,通常用於表面元素的圖示或小元件。
inverseSurface: Color, // 表面色的反色,用於需要高對比度的背景。
inverseOnSurface: Color, // 在反表面色上清晰顯示的顏色。

/* 錯誤處理色 */
error: Color, // 錯誤色,用於指示錯誤或警告狀態,如輸入校驗失敗。
onError: Color, // 在錯誤色上清晰顯示的顏色,通常用於錯誤文字或圖示。
errorContainer: Color, // 錯誤色的容器色,用於錯誤狀態的背景。
onErrorContainer: Color, // 在錯誤容器色上清晰顯示的顏色。

/* 其他 */
outline: Color, // 用於元素邊框的顏色。
outlineVariant: Color, // 邊框顏色的變體,可能用於更細微的分界線。
scrim: Color, // 遮罩層顏色,通常用於遮蓋或暗化背景中的內容。
)

文字主題:TextTheme

displayLarge:顯示大 - 顯示大是最大的顯示文字。
displayMedium:顯示中 - 顯示中是第二大的顯示文字。
displaySmall:顯示小 - 顯示小是最小的顯示文字。
headlineLarge:標題大 - 標題大是最大的標題,專為簡短、重要的文字或數字保留。對於標題,您可以選擇富有表現力的字型,例如展示、手寫或手寫體風格。這些非傳統的字型設計具有細節和複雜性,有助於吸引眼球。
headlineMedium:標題中 - 標題中是第二大的標題,專為簡短、重要的文字或數字保留。對於標題,您可以選擇富有表現力的字型,例如展示、手寫或手寫體風格。這些非傳統的字型設計具有細節和複雜性,有助於吸引眼球。
headlineSmall:標題小 - 標題小是最小的標題,專為簡短、重要的文字或數字保留。對於標題,您可以選擇富有表現力的字型,例如展示、手寫或手寫體風格。這些非傳統的字型設計具有細節和複雜性,有助於吸引眼球。
titleLarge:標題大 - 標題大是最大的標題,通常為長度較短的中等強調文字保留。襯線或無襯線字型非常適合副標題。
titleMedium:標題中 - 標題中是第二大的標題,通常為長度較短的中等強調文字保留。襯線或無襯線字型非常適合副標題。
titleSmall:標題小 - 標題小是最小的標題,通常為長度較短的中等強調文字保留。襯線或無襯線字型非常適合副標題。
bodyLarge:主體大 - 主體大是最大的主體,通常用於長篇寫作,因為它適用於較小的文字尺寸。對於較長的文字部分,建議使用襯線或無襯線字型。
bodyMedium:主體中 - 主體中是第二大的主體,通常用於長篇寫作,因為它適用於較小的文字尺寸。對於較長的文字部分,建議使用襯線或無襯線字型。
bodySmall:主體小 - 主體小是最小的主體,通常用於長篇寫作,因為它適用於較小的文字尺寸。對於較長的文字部分,建議使用襯線或無襯線字型。
labelLarge:標籤大 - 標籤大文字是用於不同型別按鈕(如文字、輪廓和包含按鈕)以及選項卡、對話方塊和卡片的行動呼籲。按鈕文字通常為無襯線,使用全部大寫文字。
labelMedium:標籤中 - 標籤中是最小字型之一。它很少用於註釋影像或介紹標題。
labelSmall:標籤小 - 標籤小是最小字型之一。它很少用於註釋影像或介紹標題。

19、螢幕適配
首先要弄懂兩個概念:物理畫素(pixel)和邏輯畫素(point)
1、物理畫素(px)呢就是手機螢幕真實的畫素點,比如iPhone 3GS螢幕上有320x480=153600個畫素點,而iphone4之後採用Retina螢幕,在物理尺寸不變的情況下,畫素成倍增加,Phone 4螢幕上則有640 x 960 = 614400個畫素點,畫素個數是原來的4倍。

2、這樣就出現了一個問題,怎麼樣讓原來的App執行在新的手機上面? 為了執行之前的App,Apple引入了一個新的概念:point (點),也就是邏輯畫素(pt)。在iPhone 3GS中,一個點等於一個畫素,也就是說點與畫素可以直接互換;在iPhone 4中,一個點等於兩個畫素;在iPhone 7 Plus中,一個點等於三個畫素。
所以iPhone6的尺寸是375x667,但是它的解析度其實是750x1334。其中375x667是邏輯畫素(pt),750x1334是物理畫素(px)

3、dpr(devicePixelRatio),解析度比(物理畫素:邏輯畫素)。iPhone 3GS的dpr是1.0,iPhone6的dpr是2.0,iPhone6plus的dpr是3.0。

4、在ios開發者,我們設定尺寸或者字型時從不填單位,因為它使用單位就是點pt。Flutter開發也是一樣,使用的是對應的邏輯解析度

5、但是在UI設計稿裡一般使用的都是物理畫素px,所以我們經常除2才是開發用到的數字。比如UI設計圖中經常選擇中間尺寸750 x1334px作為基準,向下適配640x1136px,向上適配1242x2208px和750x1624px/1125x2436px。

  瞭解了這些基本知識後,我們再來看一下Flutter的螢幕適配。其實原理和iOS是一樣的,都是【顯示螢幕寬度/設計稿基準螢幕寬度=縮放係數,然後用縮放係數*長度(寬度、字號)來進行適配。
  那麼首先就需要拿到螢幕的寬高度資訊,我們可以透過MediaQuery.of(context).size和window.physicalSize兩種方式來獲取。但是MediaQuery需要獲取上下文,不方便如果獲取時機不當也容易報錯,所以這裡透過的方式獲取。

    final width = window.physicalSize.width;
    final height = window.physicalSize.height;
    final dpr = window.devicePixelRatio;

  拿到了寬高和解析度比,我們只需要進行一些運算就可以進行適配了,下面的GCSizeFit是自己封裝的一個適配的工具類,並且對Int類和double類做了擴充,直接在數值後面點pt或者px就自動進行適配。需要注意的是:在使用GCSizeFit之前,需要先呼叫initialize建構函式進行初始化,這樣才能獲取畫素資訊。

//大多數都是以iphone6s的尺寸750 x1334px作為基準,假如實際開發中是以12Pro Max1284*2778出的基準稿,只需要初始化時設定standardWidth=1284和asset=3即可。
class GCSizeFit {
  static late double screenWidth;
  static late double screenHeight;
  static late double statusHeight;
  static late double bottomHeight;
  static late double rpx;
  static late double pt;
  //傳入基準手機物理寬度,預設是6s的750,單位是px,asset:@2x就傳2,@3x就傳3,預設是6s的2
  static void initialize ({int standardWidth = 750, int asset = 2}) {
    //手機物理寬高
    final physicalWidth = window.physicalSize.width;
    final physicalHeight = window.physicalSize.height;
    //dpr
    final dpr = window.devicePixelRatio;
    //螢幕寬高
    screenWidth = physicalWidth / dpr;
    screenHeight = physicalHeight / dpr;
    //劉海及安全區域高度
    statusHeight = window.padding.top / dpr;
    //底部安全區域高度
    bottomHeight = window.padding.bottom / dpr;
    //計算rpx的大小
    rpx = screenWidth / standardWidth;
    //計算px的大小
    pt = rpx * asset;
    print('GCSizeFit{螢幕寬: $screenWidth, 螢幕高: $screenHeight, dpr:$dpr\n'
        '劉海及安全區域高: $statusHeight, \n'
        '底部安全區域高: $bottomHeight, \n'
        'rpx: $rpx, pt: $pt}');
  }

  //畫素為單位的前端用這個,單位為px
  static double setRPX(num size) {
    return rpx * size;
  }
  //藍湖pt為單位的移動端用這個  單位為pt
  static double setPT(num size) {
    return pt * size;
  }
}

/*
* 擴充套件double
* 使用 123.45.px 或123.45.pt
* */
extension DoubleFit on double {
  double get px {
    return GCSizeFit.setRPX(this);
  }
  double get pt {
    return GCSizeFit.setPT(this);
  }
}

/*
* 擴充套件int
* 使用 123.px 或123.pt
* */
extension IntFit on int {
  double get px {
    return GCSizeFit.setRPX(this);
  }
  double get pt {
    return GCSizeFit.setPT(this);
  }
}

使用:

//在使用GCSizeFit之前,需要先呼叫initialize建構函式進行初始化,這樣才能獲取畫素資訊。
GCSizeFit.initialize();

Container(
          //Flutter開發移動端使用的單位是pt
          width: GCSizeFit.setPT(200),
          height: 200.pt,
          color: Colors.lime,
 );

        當然也可以藉助一些第三方庫,比如flutter_screenutil(https://github.com/OpenFlutter/flutter_screenutil),其實現原理與我們的方法相同。常用的幾個方法如下:
            尺寸適配:flutter_screenutil提供了setWidth()[根據螢幕寬度適配]和setHeight()[根據螢幕高度適配]方法。一般來說,控制元件尺寸都是根據寬度進行適配的,所以基本呼叫setWidth()就行,但某些特殊情況需要按高度適配的話,則需要執行setHeight()
            字型適配:flutter_screenutil提供了setSp()方法  

  如果獲取一些裝置相關的資訊,可以使用官方提供的一個庫:device_info_plus ,比如裝置的名稱、作業系統的版本、裝置的型號、唯一識別符號等等


20、應用資訊

  • 設定應用包名(Bundle identifier)

    1.Android
      Android應用標識在對應的Android目錄下:Android/app/build.gradleapplicationId:是打包時的應用標識。然後,快捷鍵Command + Shift + F全域性搜尋使用的包名,全部替換成新包名。
    2.iOS
      iOS應用標識在對應的iOS目錄下:ios/Runner/Info.plis,,找到key為CFBundleIdentifier的鍵值對,對其值進行設定(但最好透過Xcode開啟iOS工程來進行修改)。
      還有種方法就是開啟ios/Runner.xcodeproj/project.pbxproj 檔案,搜尋PRODUCT_BUNDLE_IDENTIFIER,檢視當前iOS使用的包名。然後全域性搜尋使用的包名,全部替換成新包名。

  • 設定應用名稱

    1.Android
      編輯 android/app/src/main/AndroidManifest.xml 檔案,並設定'android:label' 屬性:
    2.iOS
      編輯 ios/Runner/Info.plist 檔案,並設定CFBundleDisplayNameCFBundleName(可以透過Xcode開啟來進行修改)。
      CFBundleName 是應用程式的內部識別符號,用於檔案系統中的標識和唯一性。而 CFBundleDisplayName 是應用程式的使用者可見名稱,用於在裝置上顯示給使用者。
      在大多數情況下,開發者會將 CFBundleDisplayName 設定為更友好和描述性的名稱,以便使用者能夠輕鬆識別和使用應用程式。

  • 設定應用版本號

    在 pubspec.yaml 檔案中,您可以使用 version 欄位來設定版本號和構建號,格式為 x.x.x+x,例如:1.0.0+1。這裡 1.0.0 是版本號,+1 是構建號。每次釋出新版本到應用商店時,都應該增加構建號。
    在pubspec.yaml 設定的版本資訊會自動更新到安卓專案和iOS專案上,但我們也可以分別手動設定,方法如下:
      1.Android
        android/app/build.gradle 檔案中,versionCodeversionName 這一對屬性就是用來設定版本號和構架版本的,預設是從 pubspec.yaml 檔案中自動獲取
        當然也可以手動修改,其中versionCode是構建號,需要傳一個int值,例如3;而versionName則是版本號,需要傳一個字串,例如"1.1.2"
      2.iOS
        而iOS可以透過ios/Runner/Info.plis 裡面的CFBundleShortVersionString(版本號)和 CFBundleVersion(構建號)去進行設定,預設也是從 pubspec.yaml 檔案中自動獲取。
  -------------------------------題外話---------------------------------------
  我們在pubspec.yaml檔案中修改version可以控制名稱,但還有其他屬性,比如namedescription 等。那是不是修改這裡的name就是修改app的名稱呢?並不是。
  這裡的name表示包名(package name),引入其他檔案時需要使用此包名:import 'package:flutter_app/home_page.dart';如果這裡修改的話,那麼引入檔案的路徑也需要隨之修改;
  而description 屬性是一個可選配置屬性,是對當前專案的介紹。如果作為外掛釋出到 pub.dev 上,此值會顯示在對應的網頁位置

  • 設定最低支援系統版本和目標版本

  在Flutter開發中,設定應用的最低支援系統版本和目標版本需要在特定平臺的專案設定中進行。這裡分別介紹如何為Android和iOS設定這些版本。
  1.Android
    修改android/app/build.gradle檔案,找到defaultConfig部分,然後設定minSdkVersion(最低支援版本)targetSdkVersion(目標版本)。與iOS只有一個最低版本的設定不同,安卓需要設定一個最低版本和目標版本。其中,
      minSdkVersion: 最小版本。它表示APP可以支援的Android SDK的最低版本. 意為小於該版本的Android系統上不保證APP正常執行。
      targetSdkVersion:目標版本。表示開發者已經測試過的最高的Android版本. 當新版本的Android可用的時候, 我們應在新版本上測試APP並更新這個值以匹配最新版本的API,從而使用新版本的功能。
    這裡需要填的數值是Android SDK的API版本,並不是Android系統版本。Android SDK版本查詢 https://developer.android.google.cn/tools/releases/platforms?hl=zh-cn。例如minSdkVersion設定為16表示應用程式最低支援Android 4.1(Jelly Bean)。targetSdkVersion設定為30表示應用程式針對的是Android 11。應該根據實際需要設定這些值。
  2.iOS
    方法1:全域性搜尋IPHONEOS_DEPLOYMENT_TARGET, 然後修改部署目標版本。
    方法2:在Xcode中開啟iOS專案進行設定。

  需要注意的一點:在某些情況下,Flutter外掛可能需要特定版本的平臺SDK。這些要求通常在外掛的pubspec.yaml檔案中指定。確保你的專案pubspec.yaml檔案中列出的所有依賴項都支援你設定的最低平臺版本。查詢最低支援,Flutter Packages 網站(https://pub.dev)。

  • 設定應用Icon

  1.Android
    1)Android通常需要多個圖示大小以適應不同的裝置螢幕密度。圖示通常放在 android/app/src/main/res/ 目錄下的不同的 mipmap-型別 資料夾中,例如mipmap-mdpi資料夾、mipmap-hdpi資料夾、mipmap-xhdpi資料夾、mipmap-xxhdpi資料夾、mipmap-xxhdpi資料夾。
    2)將圖示檔名保持為 ic_launcher.png,替換所有的 mipmap-型別 資料夾中的 ic_launcher.png 檔案
    3)如果你的圖示名稱或位置有所不同,更新 android/app/src/main/AndroidManifest.xml 檔案中的 <application> 標籤的 android:icon 屬性。
  2.iOS
    iOS的應用圖示在ios/Runner/Assets.xcassets/AppIcon.appiconset中管理(可以直接開啟Xcode將對應的圖示拖入
  3.使用自動化工具
    使用第三方工具來自動化這一過程。例如,flutter_launcher_icons,提供了一種簡單的方式來同時為Android和iOS生成應用圖示。使用步驟如下:
      ①將 flutter_launcher_icons 新增到 pubspec.yaml 檔案中的 dev_dependencies 部分:

                 dev_dependencies:
                       flutter_launcher_icons: "^0.9.2"

                 flutter_icons:
                     android: true //是否為Android專案生成圖示
                     ios: true  //是否為iOS專案生成圖示
                     image_path: "assets/icon/app_icon.png" //圖示Icon路徑
                      # 你也可以為不同的平臺指定不同的圖示檔案
                      # image_path_android: "assets/icon/app_icon_android.png"
                      # image_path_ios: "assets/icon/app_icon_ios.png"
                      # 可以新增更多的配置項,如適用於Android的adaptive_icon_background等

    ②執行以下命令來生成應用圖示,flutter_launcher_icons 將根據你指定的源圖示檔案 app_icon.png 自動生成需要的各種尺寸的圖示,並替換 iOS 和 Android 專案中的現有圖示。

flutter pub get
flutter pub run flutter_launcher_icons:main

  這樣就不需要手動進入 Xcode 或 Android Studio 設定應用圖示,flutter_launcher_icons 已經自動完成了這些步驟。不過,請確保在執行上述命令之前關閉 Xcode 和 Android Studio,因為這些工具可能會鎖定一些檔案,導致 flutter_launcher_icons 無法正確寫入新圖示。

  • 設定啟動頁

  1.Android
    Android中預設的啟動圖是一片空白的,這是Flutter的預設設定效果。如果需要修改的話,在android/app/src/main/res/drawable/launch_background.xml進行修改。
      1)首先將不同尺寸的啟動圖分別新增到我們設定Icon時用到的 mipmap-型別 資料夾中,統一命名為launch_image.png
      2)將launch_background.xml中的android:src這一部分程式碼註釋開啟,然後就可以了。

    但有時候,我們是之後發現沒有效果。這個時候需要看一下res目錄中,是不是有多個drawable資料夾(比如資料夾drawable、資料夾drawable-v21),分別進行修改。
    這是因為應用載入drawable會根據API的不同,分別到對應的drawable-v數字資料夾下去進行查詢。比如 drawable-v21: 這個資料夾用於存放針對Android 5.0(API級別21)及更高版本的特定版本的可繪製資源。當應用執行在 Android 5.0 及更高版本時,系統會優先載入這個資料夾下的資源
    drawable-v21檔名後面的數字代表安卓的版本API ,v21代表的安卓5.0

    另外在aunch_background.xml中還有個屬性android:drawable,這是控制啟動頁的背景色的,可以進行修改。
    我們可以在res/values資料夾下建立一個colors.xml,然後定義一個顏色,比如splash_color:

<resources>
<color name="splash_color">#FF00FF</color>
</resources>

    然後修改android:drawable的值即可:<item android:drawable="@color/splash_color"/>

  2.iOS
    在iOS中,我們可以直接替換ios\Runner\Assets.xcassets\LaunchImage.imageset中的三張啟動圖就行。但最好還是透過Xcode開啟專案,透過故事板(Storyboard)來配置啟動頁。

  3.使用自動化工具
    同樣,我們也可以使用一些三方庫來進行配置,簡化操作。flutter_native_splash 是一個流行的 Flutter 外掛,可以自動幫我們完成上述操作,用於輕鬆地生成和配置本地化的啟動頁
      1)首先,要在專案的 pubspec.yaml 檔案中新增 flutter_native_splash 作為一個開發依賴項。

      2)在 pubspec.yaml 檔案中,你可以配置啟動頁的各種屬性,如背景顏色、圖片、文字等

                  flutter_native_splash:
                      color: "#42a5f5"  //用於設定啟動圖的背景顏色
                      image: "assets/icons/icon_launch.jpg"  //指定啟動圖資原始檔的路徑
                      android: true    //是否為android平臺生成閃屏介面
                      ios: true    //是否為iOS平臺生成閃屏介面
                      duration: 2500, //閃屏時間為2.5s
 
                        # 從 Android 12 開始,在所有應用的冷啟動和溫啟動期間,系統一律會應用Android 系統的預設啟動畫面。系統預設啟動畫面由應用的啟動器圖示元素和主題的 windowBackground(如果是單色)構成。
                        # 官網說明https://developer.android.google.cn/develop/ui/views/launch/splash-screen?hl=zh-cn
                      android_12:
                        # image引數設定閃屏圖示影像。 如果不指定該引數,將使用應用程式的啟動器圖示。
                        # 請注意,初始螢幕將被裁剪為螢幕中心的圓圈。
                        # 帶有圖示背景的應用程式圖示:這應該是 960×960 畫素,並且適合一個圓圈
                        # 沒有圖示背景的應用程式圖示:這應該是 1152×1152 畫素,並且適合一個圓圈
                        image: "assets/icons/icon_launch.jpg"

                        # 啟動畫面背景顏色。
                        color: "#161517"

                        # 應用程式圖示背景顏色。
                        #icon_background_color: "#111111"

                        # 該屬性允許你指定影像作為商標在閃屏介面顯示。它必須是 png 檔案。現在它只支援 Android 和 iOS 。
                       #branding: assets/dart.png

    3)生成啟動頁:配置好 pubspec.yaml 檔案後,執行以下命令以生成啟動頁:

flutter pub get
dart run flutter_native_splash:create

    4)如果不想要啟動圖了,想恢復 Flutter 預設的白色啟動頁,可以執行下面指令:

dart run flutter_native_splash:remove
  • 環境判斷

  常量kReleaseMode,它會根據你的應用是以什麼模式編譯的來獲取值。kReleaseMode是foundation庫的一部分,這意味著你不需要手動定義它,可以直接使用。有以下三種模式:
    kDebugMode: 當應用在Debug模式下執行時為true。Debug模式可以同時在物理裝置、模擬器或者模擬器上執行應用。預設情況下,使用flutter run命令執行應用程式時就是使用的Debug模式。
    kProfileMode: 當應用在Profile模式下執行時為true。此模式只能在物理裝置上執行,不能在模擬器上執行。使用flutter run --release命令執行應用程式時就是使用的Release模式。
    kReleaseMode: 當應用在Release模式下執行時為true。 Profile模式只能在物理裝置上執行,不能在模擬器上執行。此模式主要用於應用效能分析,一些應用除錯能力是被保留的,目的是分析應用存在的效能問題。
  由於 Profile 與 Release 在編譯過程上幾乎無差異,因此我們今天只討論 Debug 和 Release 模式。

/* 當應用以Release模式編譯時(例如執行flutter build apk或flutter build ios),kReleaseMode會被設定為true。
  當應用在Debug模式或Profile模式下執行時,kReleaseMode會被設定為false。*/
if (kReleaseMode) {
print("dart.vm.product-現在是release環境.");
} else {
print("dart.vm.product-現在是debug環境.");
}

  在Xcode中,預設情況下執行或構建應用會使用Debug配置,這意味著如果你直接透過Xcode的執行按鈕(通常是頂部左側的一個播放按鈕)啟動應用,它將預設使用Debug模式。如果修改的話,可以在前往Xcode的頂部選單欄,選擇Product > Scheme > Edit Scheme > Archive > Build Configuration 進行設定。
  Android Studio 執行專案,預設安裝到手機上的 app 也屬於debug 包,而且在Android Studio 沒找到執行release版本的入口,現在需要連線上真機,透過命令列flutter run --release執行release模式。

  • 平臺判斷

  Flutter中,你可以使用Platform類來檢測應用程式正在哪個作業系統平臺上執行。這個類位於dart:io庫中。

        import 'dart:io' show Platform;
        if (Platform.isAndroid) {
            // Android平臺的程式碼
        } else if (Platform.isIOS) {
            // iOS平臺的程式碼
        } else if (Platform.isLinux) {
            // Linux平臺的程式碼
        } else if (Platform.isMacOS) {
            // macOS平臺的程式碼
        } else if (Platform.isWindows) {
            // Windows平臺的程式碼
        } else if (Platform.isFuchsia) {
            // Fuchsia平臺的程式碼
        }
  • 許可權申請

  App在原生功能訪問中都需要申請許可權後才能使用,比如儲存許可權、網路訪問許可權、定位許可權等等。在 flutter 開發中,則需要一個跨平臺(iOS, Android)的 API 來請求許可權和檢查他們的狀態,這時候就需要使用 flutter 外掛permission_handler來幫忙了,它允許您查看和申請相關許可權
  使用介紹,詳見印象筆記:https://app.yinxiang.com/fx/c03ec6da-09b8-4c0d-8109-e90a01c649dc


21、國際化

  • Widget元件國際化

  Flutter給我們提供的Widget預設情況下就是支援國際化,比如日曆元件。但是在沒有進行特別的設定之前,它們無論在什麼環境都是以英文的方式顯示的。
  如果想要新增其他語言,應用必須指定額外的 MaterialApp 屬性並且新增一個單獨的 package,叫做 flutter_localizations
    1、在 pubspec.yaml 檔案中新增它作為依賴,這個和設定三方庫依賴不同:

    dependencies:
      #新增國際化包
      flutter_localizations:
        sdk: flutter

    2、設定MaterialApp:引數localizationsDelegates中指定哪些Widget需要進行國際化;引數supportedLocales指定要支援哪些國際化

      MaterialApp(
       localizationsDelegates: [ //我們這裡指定了Material、Widgets、Cupertino都使用國際化
         GlobalMaterialLocalizations.delegate, // 指定本地化的字串和一些其他的值
         GlobalCupertinoLocalizations.delegate, // 對應的Cupertino風格
         GlobalWidgetsLocalizations.delegate // 指定預設的文字排列方向, 由左到右或由右到左
       ],
       supportedLocales: [//我們這裡指定中文和英文(也可以指定國家編碼)
         Locale("en"),
         Locale("zh")
       ],
      )

    3、完成以上兩步,安卓專案就完成了Widget的國家化,但是iOS還不行,需要對iOS專案中對應的info.plist檔案進行修改

      用Xcode開啟iOS專案中對應的info.plist檔案,選擇 Information Property List 項;
      從 Editor 選單中選擇 Add Item,然後從彈出選單中選擇 Localizations
      為array新增一項選擇 Add Item,選擇Chinese;

  • 文字國際化

  App中除了有預設的Widget,我們也希望對自己的文字進行國際化,其原理和iOS做國家化是一樣的,就是不同的環境載入不同的語言檔案包。比較流行的方法是透過Flutter Intl外掛實現【也可以透過GetX框架實現國際化】。
    1.在Android Studio的Plugins中安裝Flutter Intl外掛

    2.初始化intl,選擇工具欄Tools - Flutter Intl - Initialize for the Project。完成上面的操作之後會自動生成如下檔案目錄:
      *generated是自動生成的dart程式碼
      *I10n是對應的arb檔案目錄(arb全稱Application Resource Bundle,表示應用資源包),我們適配的語言檔案就是在這裡。

    3.使用intl
      ①在localizationsDelegates新增對應的delegate:S.delegate
      ②supportedLocales使用S.delegate.supportedLocales。(我們在widget國際化時,需要設定為Locale("en")和Locale("zh")。設定為S.delegate.supportedLocales,它會根據新增的語言環境去自動適配widget的國際化,比如intl增加了中文語言,widget也會增加中文的支援,所以只設定為S.delegate.supportedLocales就行。)
      ③在intl_en.arb檔案中進行編寫儲存

{
"title": "home",
"greet": "hello~",
"picktime": "Pick a time"
}

      ④增加其他語言支援,Tools - Flutter Intl - add local,比如如果希望新增中文支援,在彈出框中輸入zh即可。然後在intl_zh_.arb檔案中進行編寫儲存:

{
"title": "首頁",
"greet": "您好~",
"picktime": "選擇一個時間"
}

      ⑤在程式碼中使用即可,按照如下格式:S.of(context).title

intl_zh.arb檔案
     {
          "title" : "標題"
     }

intl_en.arb檔案
     {
       "title" : "title"
     }

main.dart檔案
void main(){
  runApp(
      MaterialApp(
          localizationsDelegates: [
            GlobalMaterialLocalizations.delegate, // 指定本地化的字串和一些其他的值
            GlobalCupertinoLocalizations.delegate, // 對應的Cupertino風格
            GlobalWidgetsLocalizations.delegate, // 指定預設的文字排列方向, 由左到右或由右到左
            S.delegate
          ],
          // supportedLocales: [//我們這裡指定中文和英文(也可以指定國家編碼)
          //   Locale("en"),
          //   Locale("zh")
          // ],
          supportedLocales: S.delegate.supportedLocales,
          home:myAppInternationalScaffold()
      )
  );
}

_showDatePicker(context) async{
  var date =await showDatePicker(
      context: context,
      initialDate: DateTime.now(),
      firstDate:DateTime(1900),
      lastDate:DateTime(2050)
  );
  if(date==null) return;
  print(date);
}

class myAppInternationalScaffold extends StatelessWidget{
  @override
  Widget build(BuildContext context) {
    return Scaffold(
          appBar: AppBar(
            title: Text(S.of(context).title),
          ),
          body:Center(
            child:Container(
              color: Colors.red,
              width: 200,
              height: 200,
            ),
          ),
          floatingActionButton: FloatingActionButton(onPressed: (){
            _showDatePicker(context);
          })
      );
  }
}

22、資料儲存(資料持久化)
資料持久化是指將應用程式中的資料儲存在持久儲存介質(如硬碟、資料庫等)中的過程。Flutter中的資料持久化方式:

  • Shared Preferences,用於儲存簡單的資料型別(如字串、整數、布林值等)的鍵值對,類似於Android中的SharedPreferences或iOS中的UserDefaults。適用於儲存簡單的配置、設定和使用者偏好資料。

    * 1.首先在pubspec.yaml檔案中新增依賴:shared_preferences: ^2.0.0
    * 2.透過SharedPreferences.getInstance()建立一個單例物件;
    * 3.透過set方法設定鍵值對,透過get方法透過key取出對應的值。支援儲存五種資料格式:setBool、setDouble、setInt、setString、setStringList

  • 檔案儲存(File Storage),檔案儲存適用於儲存結構化資料或需要較長時間儲存的資料。可以使用Dart的dart:io庫來操作檔案。常用的儲存格式包括JSON、XML等。

    * 1.首先我們需要先新增path provider的依賴,這個包有助於獲取檔案儲存的目錄路徑。根據getApplicationDocumentsDirectory()方法獲取根路徑,然後在拼接檔案的儲存路徑;
    * 2.透過File類,建立對應路徑的檔案。
    * 3.透過File的write方法進行檔案寫入,可以寫入Bytes和String兩種資料
    * 4.讀取也是一樣,給File類輸入對應的路徑,然後執行read方法就能讀取資料

  • SQLite資料庫,一種嵌入式關係資料庫,適用於需要複雜資料查詢和關係操作的情況,但使用相對較複雜,需要熟悉SQL語法。Flutter可以透過第三方庫sqflite來訪問SQLite資料庫。

    * 1.新增sqflite依賴:sqflite: ^2.0.0
    * 2.透過openDatabase方法根據路徑建立或開啟一個資料庫,透過version引數置設版本,透過onCreate引數執行CREATE TABLE test(id INTEGER PRIMARY KEY, name TEXT)指令新建一個表test
    * 3.然後分別透過insert、delete、update和query方法實現增刪改查的功能;當然也可以透過rawInsert、rawDelete、rawUpdate和rawQuery方法實現,但是這幾個方法需要直接使用SQL語句。而不帶row的則是Android自己封裝的查詢API,會根據內容幫你拼寫 SQL 語句。

  • 使用第三方資料庫服務(如Hive)

  因為使用SQLite比較麻煩,所以我們可以使用第三方資料庫服務,比如Hive。這是一個輕量級、鍵值對儲存的資料庫,適合在移動裝置上高效儲存資料。它不需要初始化非同步操作,因此在很多情況下比SQLite更簡單和高效。
    *1、首先在pubspec.yaml檔案中新增依賴,在dependencies中新增了hive的庫。

dependencies:
  hive: ^2.2.3
  hive_flutter: ^1.1.0

    * 2、在main方法裡初始化:await Hive.initFlutter();
    * 3、在Hive中,所有的資料都被儲存到box中。Box是Hive中的最小儲存單元,類似於檔案系統中的資料夾,我們可以使用它來儲存和檢索資料。在使用box之前,必須先開啟它。我們可以使用Hive.openBox()方法開啟一個box:var box = await Hive.openBox<E>('testBox');
    * 4、在Hive中,資料是以鍵值對的形式儲存的。我們可以使用box的put方法,將資料儲存到Box裡面
    * 5、使用 get() 方法可以獲取資料,如果key對應的value不存在的話,會返回 null,為了避免這種情況的發生,我們可以設定一個返回值:_box.get('key', defaultValue: 'No Value')。
    * 6、刪除資料可以選擇用null覆蓋或者直接刪除:_box.delete("age");box.put("age", null);
    * 7、使用完後要記得關閉,所有活動的讀寫操作完成後,盒子的所有快取鍵和值將從記憶體中刪除,並且盒子檔案將關閉:await box.close();。

---------------------------------------------示例程式碼---------------------------------------------

                      TextEditingController _controller = TextEditingController();
                      Database? _database;
                      late Box _box;

                    //儲存資料
                    /*Shared Preferences,用於儲存簡單的資料型別(如字串、整數、布林值等)的鍵值對,類似於Android中的SharedPreferences或iOS中的UserDefaults。適用於儲存簡單的配置、設定和使用者偏好資料。
                     *
                     * 1.首先在pubspec.yaml檔案中新增依賴:shared_preferences: ^2.0.0
                     * 2.透過SharedPreferences.getInstance()建立一個單例物件;
                     * 3.透過set方法設定鍵值對,透過get方法透過key取出對應的值。支援儲存五種資料格式:setBool、setDouble、setInt、setString、setStringList
                     * */
                    Container(
                      width: 200,
                      height: 44,
                      color: Colors.blueGrey,
                      child: TextButton(
                        child:Row(
                            mainAxisAlignment: MainAxisAlignment.center,
                            children:[
                              Icon(Icons.save,color: Colors.white,),
                              Text("  儲存姓名",style: TextStyle(color: Colors.white,fontSize: 20))
                            ]),
                        onPressed: () async{
                          //獲取輸入結果
                          print(_controller.text);
                          SharedPreferences prefs = await SharedPreferences.getInstance();
                          prefs.setString("UserName", _controller.text);
                        },
                      ),
                    ),

                    /*
                    * File Storage,檔案儲存適用於儲存結構化資料或需要較長時間儲存的資料。可以使用Dart的dart:io庫來操作檔案。常用的儲存格式包括JSON、XML等。
                    *
                    * 1.首先我們需要先新增path provider的依賴,這個包有助於獲取檔案儲存的目錄路徑。根據getApplicationDocumentsDirectory()方法獲取根路徑,然後在拼接檔案的儲存路徑;
                    * 2.透過File類,建立對應路徑的檔案。
                    * 3.透過File的write方法進行檔案寫入,可以寫入Bytes和String兩種資料
                    * 4.讀取也是一樣,給File類輸入對應的路徑,然後執行read方法就能讀取資料了
                    * */
                    Container(
                      width: 200,
                      height: 44,
                      color: Colors.orange,
                      child: TextButton(
                        child:Row(
                            mainAxisAlignment: MainAxisAlignment.center,
                            children:[
                              Icon(Icons.save_alt_outlined,color: Colors.white,),
                              Text("  儲存檔案",style: TextStyle(color: Colors.white,fontSize: 20))
                            ]),
                        onPressed: () async{
                          //獲取根路徑
                          final directory  = await getApplicationDocumentsDirectory();
                          final rootPath = directory.path;
                          //拼接設定檔案要儲存的路徑
                          final filePath = "$rootPath/data.txt";
                          print(filePath);// /data/user/0/com.example.gao_progect/app_flutter/data.txt

                          //透過File類,建立對應路徑的檔案。
                          final file = File(filePath);

                          //透過File的write方法進行檔案寫入
                          file.writeAsString("這裡是測試資料---這裡是測試資料");
                        },
                      ),
                    ),

                    /*SQLite,一種嵌入式關聯式資料庫,適用於需要複雜資料查詢和關係操作的情況,但使用相對較複雜,需要熟悉SQL語法。Flutter可以透過第三方庫sqflite來訪問SQLite資料庫。
                    *
                    * 1.新增sqflite依賴:sqflite: ^2.0.0
                    * 2.透過openDatabase方法根據路徑建立或開啟一個資料庫,透過version引數置設版本,透過onCreate引數執行CREATE TABLE test(id INTEGER PRIMARY KEY, name TEXT)指令新建一個表test。
                    * 3.然後分別透過insert、delete、update和query方法實現增刪改查的功能;當然也可以透過rawInsert、rawDelete、rawUpdate和rawQuery方法實現,但是這幾個方法需要直接使用SQL語句。而不帶row的則是Android自己封裝的查詢API,會根據內容幫你拼寫 SQL 語句。
                    * */
                    Container(
                      width: 200,
                      height: 44,
                      color: Colors.deepPurple,
                      child: TextButton(
                        child:Row(
                            mainAxisAlignment: MainAxisAlignment.center,
                            children:[
                              Icon(Icons.table_chart_outlined,color: Colors.white,),
                              Text("  資料庫儲存",style: TextStyle(color: Colors.white,fontSize: 20))
                            ]),
                        onPressed: () async{

                          //獲取根路徑
                          final directory  = await getApplicationDocumentsDirectory();
                          final rootPath = directory.path;
                          //設定資料庫的路徑
                          final filePath = "$rootPath/example.db";

                          //透過openDatabase方法根據路徑建立或開啟一個資料庫,透過version引數置設版本,透過onCreate引數執行CREATE TABLE test(id INTEGER PRIMARY KEY, name TEXT)指令新建一個表。
                          _database = await openDatabase(
                              filePath,
                              version: 1,
                              onCreate: (db, version) {
                                return db.execute(
                                   "CREATE TABLE test(id INTEGER PRIMARY KEY, name TEXT)",
                                );
                              }
                          );

                          _database?.insert("test", {'id': 1, 'name': 'Flutter'});

                          /*安卓版增刪改查
                          _database.insert(table, values)
                          _database.delete(table)
                          _database.update(table, values)
                          _database.query(table)
                           */

                          /*SQL語句版增刪改查
                          _database.rawInsert(sql)
                          _database.rawDelete(sql)
                          _database.rawUpdate(sql)
                          _database.rawQuery(sql)
                           */
                        },
                      ),
                    ),

                    /*因為使用SQLite比較麻煩,所以我們可以使用第三方資料庫服務,比如Hive。Hive是一個輕量級的、鍵值對儲存的資料庫,適合在移動裝置上高效儲存資料。它不需要初始化非同步操作,因此在很多情況下比SQLite更簡單和高效。
                    *
                    * 1、首先在pubspec.yaml檔案中新增依賴,在dependencies中新增了hive的庫。
                    *     dependencies:
                    *       hive: ^2.2.3
                    *       hive_flutter: ^1.1.0
                    * 2、在main裡初始化:await Hive.initFlutter();
                    * 3、在Hive中,所有的資料都被組織到box中。Box是Hive中的最小儲存單元,類似於檔案系統中的資料夾,我們可以使用它來儲存和檢索資料。在使用box之前,必須先開啟它。我們可以使用Hive.openBox()方法開啟一個box。var box = await Hive.openBox<E>('testBox');
                    * 4、在Hive中,資料是以鍵值對的形式儲存的。我們可以使用box的put方法,將資料儲存到Box裡面。
                    * 5、使用 get() 方法可以獲取資料,如果key對應的value不存在的話,會返回 null,為了避免這種情況的發生,我們可以設定一個返回值:_box.get('key', defaultValue: 'No Value')。
                    * 6、可以選擇用null覆蓋或者直接刪除:_box.delete("age");或box.put("age", null);
                    * 7、使用完後要記得關閉,所有活動的讀寫操作完成後,盒子的所有快取鍵和值將從記憶體中刪除,並且盒子檔案將關閉:await box.close();
                    * */
                    Container(
                      width: 200,
                      height: 44,
                      color: Colors.blueAccent,
                      child: TextButton(
                        child:Row(
                            mainAxisAlignment: MainAxisAlignment.center,
                            children:[
                              Icon(Icons.table_chart_sharp,color: Colors.white,),
                              Text("  Hive",style: TextStyle(color: Colors.white,fontSize: 20))
                            ]),
                        onPressed: () async{
                          
                          //開啟box
                          final boxName = "test";
                          if (!Hive.isBoxOpen(boxName)) {
                            _box = await Hive.openBox(boxName); // 開啟 Hive Box 例項
                          } else {
                            _box = Hive.box(boxName); // 如果已經開啟,則獲取例項
                          }
                          //將資料儲存到Box裡面
                          _box.put("saveKey", ["testValue1","testValue2"]);
                        },
                      ),
                    ),


//讀取儲存的資料
onPressed: ()async{
          //Shared Preferences
          //呼叫SharedPreferences單例物件的get方法,獲取key對應的資料
          SharedPreferences prefs = await SharedPreferences.getInstance();
          var value = prefs.getString("UserName");
          print("儲存的資料為----$value");


          //File Storage讀取也是一樣,給File類輸入對應的路徑,然後執行read方法就能讀取資料了
          final directory  = await getApplicationDocumentsDirectory();
          final rootPath = directory.path;
          //拼接設定檔案要儲存的路徑
          final filePath = "$rootPath/data.txt";

          try{//防止讀取時發生錯誤
            //透過File類,獲取對應路徑的檔案。
            final file = File(filePath);

            //透過File的read方法進行檔案寫入
            final content = await file.readAsString();
            print("File檔案儲存內容----$content");
          }catch(e){//讀取檔案發生錯誤
            print("Error reading data!");
          }
          
          
          //資料庫讀取
          final maps = await _database?.rawQuery("SELECT * FROM test");
          print("資料庫讀取---$maps");


          //Hive資料讀取
          final result = _box.get("saveKey",defaultValue: "empty value");
          print("Hive讀取---$result");
 }

23、動畫
FLutter 中的動畫主要分為:隱式動畫、顯式動畫、自定義隱式動畫、自定義顯式動畫和 Hero 動畫。

  • 所謂隱式動畫就是只需要設定動畫目標,過程控制由系統實現。一般是簡單點的動畫,比如只是簡單的寬高變化,沒有迴圈重播,不用隨時中斷,沒有多方協調,就是從開始執行到結束。使用flutter提供的api,隱式動畫一般是Animated...開頭。AnimatedContainer、AnimatedPadding、AnimatedPositioned、AnimatedOpacity、AnimatedDefaultTextStyle、AnimatedSwitcher都屬於隱式動畫,雖然他們關注動畫的側重點不同,但也支援其他部分的動畫。比如在AnimatedPadding設定尺寸變化動畫也是支援的,但必須得設定aliment屬性值。
         * AnimatedOpacity 在透明度opacity發生變化時執行過渡動畫到新狀態,Opacity屬性是必傳值。
         * AnimatedContainer 當Container屬性發生變化時會執行過渡動畫到新的狀態,比如顏色值、大小等。
         * AnimatedPadding 在padding發生變化時會執行過渡動畫到新狀態,Padding屬性是必傳值
         * AnimatedPositioned 配合Stack一起使用,當定位狀態發生變化時會執行過渡動畫到新的狀態。
         * AnimatedAlign 當alignment發生變化時會執行過渡動畫到新的狀態,alignment屬性是必傳值
         * AnimatedSwitcher 當內部元件發生變化時會執行過渡動畫到新的狀態。引數transitionBuilder:一個回撥函式,用於定義子元素切換時的過渡效果。需要注意的是,必須給子控制元件指定一個UniqueKey,這樣才能強制渲染重新整理

      動畫效果圖詳見https://blog.csdn.net/Taonce/article/details/136790922

  隱式動畫中可以透過 duration 配置動畫時長、可以透過 Curve (曲線)來配置動畫過程onEnd參數列示的是動畫結束的回撥

Curves 類提供了一系列預定義的曲線,用於控制動畫的速度變化。以下是一些常用的 Curves 元件的值及簡單解釋:
Curves.linear:線性曲線,動畫以恆定的速度進行,沒有加速或減速。
Curves.decelerate:減速曲線,動畫開始時速度較快,然後逐漸減速。
Curves.ease:標準的加速減速曲線,動畫開始和結束時速度較慢,中間時速度較快。
Curves.easeIn:加速曲線,動畫開始時速度較慢,然後逐漸加速。
Curves.easeOut:減速曲線,動畫開始時速度較快,然後逐漸減速。
Curves.easeInOut:加速減速曲線,動畫開始和結束時速度較慢,中間時速度較快,類似於Curves.ease。
Curves.fastOutSlowIn:快出慢入曲線,動畫開始時速度較快,然後逐漸減速到結束。
Curves.bounceIn:彈簧效果曲線,動畫開始時速度為0,然後加速進入動畫,到達最大速度後反彈一次。
Curves.elasticIn:彈性效果曲線,動畫開始時速度為0,然後加速進入動畫,到達最大速度後會有一些超過目標值的回彈效果。
  • 顯式動畫指的是需要手動設定動畫的時間,運動曲線,取值範圍的動畫,將值傳遞給動畫部件,使用 Animation(AnimatedWidget)AnimationController 來實現。相比隱式動畫,雖然是使用上麻煩了,但顯示動畫提供了更大的靈活性,能夠更精確地控制動畫的程序、效果和互動。

  常見的顯式動畫有 RotationTransition(旋轉)、FadeTransition(透明度)、ScaleTransition(縮放)、SlideTransition(移動)、AnimatedIcon(改變常見圖示)。

RotationTransition用於在子元件進行旋轉動畫。它可以根據指定的旋轉角度來對子元件進行旋轉,並且可以在動畫執行過程中實時更新旋轉角度以建立平滑的動畫效果。
FadeTransition用於在子元件進行透明度漸變動畫。它可以根據指定的透明度值來對子元件進行漸變動畫,並且可以在動畫執行過程中實時更新透明度值以建立平滑的動畫效果。
ScaleTransition用於在子元件進行縮放動畫。它可以根據指定的縮放比例來對子元件進行縮放動畫,並且可以在動畫執行過程中實時更新縮放比例以建立平滑的動畫效果。
SlideTransition是負責平移的顯示動畫元件,使用時需要透過 position 屬性傳入一個 Animated 表示位移程度,通常藉助 Tween 實現。
AnimatedIcon是一個用於提供動畫圖示的元件,它的名字雖然是以 Animated 開頭,但是他是一個顯式動畫元件,需要透過 progress 屬性傳入動畫控制器,另外需要由 Icon 屬性傳入動畫圖示資料。

  使用步驟:

    1.建立AnimationController
    2.建立動畫元件,繫結Conroller,設定Tween、Curve效果
    3.Controller 控制動畫的開始、暫停、重置、跳轉、倒播等
    4.監聽動畫
    5.銷燬控制器
  幾個概念:
    AnimationController 是一個控制動畫的類,它管理動畫的狀態,如開始、停止、正向或反向播放等。
    Animation 表示動畫的當前狀態,它是一個在指定範圍內的可變值。動畫的值會隨時間變化,可以用於控制 UI 元素的屬性,從而建立動畫效果。它是一個抽象類
    AnimatedWidget可以理解為動畫Animation的輔助類,可以理解為建立一個Widget自帶動畫效果,也可以理解為使用Widget來封裝複雜的組合的自定義動畫實現
    Tween: 預設情況下,AnimationController動畫生成的值所在區間是0.0到1.0,如果希望使用這個以外的值,或者其他的資料型別,就需要使用Tween

  • 過渡動畫Hero,用於在兩個頁面之間平滑地傳遞共享元素。比如微信朋友圈點選小圖片的時候會有一個動畫效果到大圖預覽,這個動畫效果就可以使用 Hero 動畫實現。簡單來說 Hero 動畫就是在兩個路由之間傳遞一個元素,使其看起來像是在連續移動

    1、使用也比較簡單,只需要用Hero元件包裹住需要傳遞的共享元件即可。
    2、然後設定一個tag標籤,用於在多個 Hero widget 之間建立關聯

  • 物理動畫,Flutter 提供了 Simulation 類,用於建立具有初始狀態和演化規則的基於物理的模擬,這個類使你能夠製作各種基於物理的動畫,包括基於彈簧動力學、摩擦力和重力的動畫。

  Simulation是個抽象類,我們一般使用它以下的幾個子類:

BouncingScrollSimulation 彈性的滾動模擬
BoundedFrictionSimulation 邊界摩擦模擬引擎
ClampedSimulation 區間模擬引擎
ClampingScrollSimulation 區間滾動模擬引擎
FrictionSimulation 摩擦引數的的滾動模擬
GravitySimulation 下落重力模擬引擎
SpringSimulation 彈簧彈力的模擬
ScrollSpringSimulation 彈簧滾動模擬

  實際開發中,常常將Simulation類結合AnimationController來實現物理動畫,使用流程如下:
    1.定義一個AnimationController,用來控制動畫資訊;
    2.透過控制器指定我們需要的動畫起點和終點;
    3.建立Simulation物件,設定動畫中的物理屬性;
    4.透過控制器執行動畫

------------------------------------------------------------示例程式碼--------------------------------------------------------------------

//AnimatedAlign 當Container屬性發生變化時會執行過渡動畫到新的狀態,比如寬高
class animalClass1 extends StatefulWidget{
  @override
  State<animalClass1> createState() => _animalState1();
}
class _animalState1 extends State<animalClass1> {
  bool flag = false;
  @override
  Widget build(BuildContext context) {
    return Center(
      child: GestureDetector(
        child: AnimatedContainer(
          duration: const Duration(milliseconds: 250),
          width: flag ? 100 : 200,
          height: flag ? 100 : 200,
          color: flag ?Colors.green:Colors.red,
          curve: Curves.ease, //標準的加速減速曲線,動畫開始和結束時速度較慢,中間時速度較快。
        ),
        onTap: (){
          setState(() {
            flag = !flag;
          });
        },
      )
    );
  }
}


//AnimatedAlign 當alignment發生變化時會執行過渡動畫到新的狀態,alignment是必傳值,
class animalClass2 extends StatefulWidget{
  @override
  State<animalClass2> createState() => _animalState2();
}
class _animalState2 extends State<animalClass2> {
  bool flag = false;
  @override
  Widget build(BuildContext context) {
    return Center(
        child: GestureDetector(
          child: AnimatedAlign(
            child: Container(height: flag?100:200,width: 100,color: flag?Colors.orange:Colors.blueAccent),//AnimatedAlign主要用來設定alignment動畫,alignment是必傳值,但是也支援其他部分的動畫
            duration: const Duration(milliseconds: 250),
            curve: Curves.ease,
            alignment: flag?Alignment.bottomRight:Alignment.topLeft, //標準的加速減速曲線,動畫開始和結束時速度較慢,中間時速度較快。
          ),
          onTap: (){
            setState(() {
              flag = !flag;
            });
          },
        )
    );
  }
}

//AnimatedSwitcher 當內部元件發生變化時會執行過渡動畫到新的狀態。
class animalClass3 extends StatefulWidget{
  @override
  State<animalClass3> createState() => _animalState3();
}
class _animalState3 extends State<animalClass3> {
  var _value = "開始顯示的內容";
  @override
  Widget build(BuildContext context) {
    return Center(
        child: GestureDetector(
          child: AnimatedSwitcher(
            child: Text(_value,key: UniqueKey()),//child用於設定變化前顯示的元件
            duration: const Duration(milliseconds: 250),
            transitionBuilder: (child, animation) {
              return FadeTransition(
                opacity: animation,
                child: ScaleTransition(
                  scale: animation,
                  child: child
                ),
              );
            }
          ),
          onTap: (){
            setState(() {
              _value = "內容發生變化";
            });
          },
        )
    );
  }
}

//AnimatedSwitcher 當內部元件發生變化時會執行過渡動畫到新的狀態。
class animalClass4 extends StatefulWidget{
  @override
  State<animalClass4> createState() => _animalState4();
}
class _animalState4 extends State<animalClass4> with SingleTickerProviderStateMixin{
  late AnimationController _controller;
  @override
  void initState() {
    // TODO: implement initState
    super.initState();
    // 1.建立AnimationController
    //Vsync 機制可以理解為是顯示卡與顯示器的通訊橋樑,顯示卡在渲染每一幀之前會等待垂直同步訊號,只有顯示器完成了一次重新整理時,發出垂直同步訊號,
    //顯示卡才會渲染下一幀,確保重新整理率和幀率保持同步,以達到供需平衡的效果,防止卡頓現象。
    _controller =
        AnimationController(vsync: this, duration: const Duration(seconds: 1));

    //4.監聽動畫
    _controller.addListener(() {//addListener方法,每當動畫的狀態值發生變化時,動畫都會通知所有透過 addListener 新增的監聽器。
      print(_controller.value);
    });

    _controller.addStatusListener((status) {//addStatusListener,當動畫的狀態發生變化時,會通知所有透過 addStatusListener 新增的監聽器。
      if (status == AnimationStatus.completed) {//正序播放完成,倒序播放
        _controller.reverse();
      } else if (status == AnimationStatus.dismissed) {//倒敘播放完了,正序播放
        _controller.forward();
      }
    });
  }
  @override
  Widget build(BuildContext context) {
    return Center(

    child: GestureDetector(
          //2.建立動畫元件,繫結Conroller,設定Tween、Curve效果
          child:ScaleTransition(
              scale: _controller.drive(Tween(begin: 1, end: 2)),//設定放大倍數是1到2倍
              child: Icon(Icons.favorite,color: Colors.red,size: 100),
          ),
          onTap: (){
            setState(() {
              //3.Controller 控制動畫的開始、暫停、重置、跳轉、倒播等
              _controller.forward();//正序播放
            });
          },
        )
    );
  }

  @override
  void dispose() {
    //5.銷燬控制器
    _controller.dispose();
    super.dispose();
  }

}


//Hero 動畫就是在兩個路由之間傳遞一個元素,使其看起來像是在連續移動
class animalClass5 extends StatelessWidget{
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        Navigator.of(context).push(MaterialPageRoute(
          builder: (context) => SecondPage(),
        ));
      },
      //1、使用也比較簡單,只需要用Hero元件包裹住需要傳遞的共享元件即可。
      child: Hero(
        //2、然後設定一個tag標籤,用於在多個 Hero widget 之間建立關聯
        tag: 'iconTag',
        child: Icon(Icons.star,size: 80,color: Colors.pink,),
      ),
    );
  }
}
class SecondPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: Hero(
          tag: 'iconTag',
          child: Icon(Icons.star,size: 80,color: Colors.pink),
        ),
      ),
    );
  }
}


//物理動畫,Simulation類結合AnimationController實現。動畫效果:拖拽widget,鬆手後,widget會按照彈簧效果回到中心位置。
class animalClass6 extends StatefulWidget{
  @override
  State<animalClass6> createState() => _animalState6();
}
class _animalState6 extends State<animalClass6> with SingleTickerProviderStateMixin{

  late AnimationController _controller;

  late Animation<Alignment> _animation;

  Alignment _dragAlignment = Alignment.center;

  @override
  void initState() {
    // TODO: implement initState
    super.initState();


    //1.定義一個AnimationController,用來控制動畫資訊
    _controller = AnimationController(vsync: this, duration: const Duration(seconds: 1));

    _controller.addListener(() {
      setState(() {
        _dragAlignment = _animation.value;
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return  GestureDetector(
      onPanUpdate: (details) {//拖拽時
        setState(() {

          //獲取螢幕尺寸
          final size = MediaQuery.of(context).size;

          _dragAlignment += Alignment(
            details.delta.dx / (size.width / 2),
            details.delta.dy / (size.height / 2),
          );
        });
      },

      onPanEnd: (details) {//鬆手後執行物理動畫

         //2.透過控制器指定我們需要的動畫起點和終點:
         _animation = _controller.drive(
          AlignmentTween( //Alignment有一個專門表示位置資訊的類叫做AlignmentTween
            begin: _dragAlignment,
            end: Alignment.center,
          ),
        );

        //3.建立Simulation物件,設定動畫中的物理屬性
        final spring = SpringDescription(//彈簧的屬性配置
          mass: 10,  //質量
          stiffness: 5,  //硬度
          damping: 0.75,  //阻尼係數
        );

        SpringSimulation simulation = SpringSimulation(spring, 0, 1, -1);

        //4.透過控制器執行動畫
        _controller.animateWith(simulation);
      },
      child: Align(
        alignment: _dragAlignment,
        child: Container(
          height: 100,
          width: 100,
          color: Colors.green,
        )
      ),
    );
  }


  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

24、訂閱、Stream、EventBus
  在 Flutter 中有兩種處理非同步操作的方式 Future 和 Stream。Future 用於處理單個非同步操作,Stream 用來處理連續的非同步操作。在執行非同步任務時,Stream可以透過多次觸發成功或失敗事件來傳遞結果資料或錯誤異常。所以Stream常用於會多次讀取資料的非同步任務場景,如網路內容下載、檔案讀寫
  Stream的字面意思上是流,那什麼是流呢?流提供了一種接收一系列事件的方法。每個事件要麼是一個資料事件,也稱為流的元素,要麼是一個錯誤事件,即某事已失敗的通知。
  Stream主要是把事件放在流上面去處理,可以接受任何型別的資料,值、事件、物件、集合、對映、錯誤、甚至是另一個Stream。
  透過StreamController中的sink作為入口,往Stream中插入資料,然後自定義監聽StreamSubscription物件,接受資料變化的通知。如果需要對輸出資料進行處理,可以使用StreamTransformer,它可以對輸出資料進行過濾、重組、修改、將資料注入其他流等等任何型別的資料操作。

  • stream都有哪些型別?

  Stream 分單訂閱流和廣播流。

    單訂閱流在傳送完成事件之前只允許設定一個監聽器,並且只有在流上設定監聽器後才開始產生事件,取消監聽器後將停止傳送事件。即使取消了第一個監聽器,也不允許在單訂閱流上設定其他的監聽器。一般建立的Stream都是單訂閱模式
    廣播流(多訂閱模式)則允許設定多個監聽器,也可以在取消上一個監聽器後再次新增新的監聽器。在建立StreamController時新增broadcast就變為多訂閱模式
  另外Stream 有同步流和非同步流之分。它們的區別在於同步流會在執行 add,addError 或 close 方法時立即向流的監聽器 StreamSubscription 傳送事件,而非同步流總是在事件佇列中的程式碼執行完成後在傳送事件。

  • Stream五大元素:

  StreamController:作為整個流的控制器,用於建立和管理一個流(Stream),它允許你新增資料到流中,並且可以監聽這些資料。sync 引數決定這個流是同步流還是非同步流。
  StreamSink:流事件入口,提供 add,addError,addStream 方法向流傳送事件。
  Stream:事件源
  StreamSubscription:訂閱管理(流的監聽器),提供 cacenl、pause, resume 等方法管理。
  StreamBuilder:StreamBuilder是Flutter中的一個Widget,它可以跟Steam結合起來使用。StreamBuilder監聽到更新然後會自動觸發 Widget 的重新整理,相比StatefulWidget,重新整理時使用setstate會將整個item 進行重新構建,StreamBuilder更節約開銷

  • Stream的建立

  Stream可以透過兩種形式去建立,一種是透過StreamController,StreamController中是有一個Stream,只需要構造出StreamController物件,透過這個物件的.stream就可以得到Stream。
  如果我們不想使用Controller的Stream,可以透過構造方法去建立,然後透過StreamSink新增到Controller去管理,而Stream的構造方法分為3種:
    Stream.fromFuture,透過傳遞一個非同步任務來建立Stream
    Stream.fromFutures,透過傳遞多個非同步任務來建立Stream
    Stream.fromIterable 透過傳遞一個集合來建立Stream,集合中的每一個資料都會有自己的回撥

  • stream有哪些好處?

  1.隨意運算元據流。
    剛才在stream定義那裡已經說過了,stream是基於資料流的,從skin管道入口到StreamController提供stream屬性作為資料的出口之間,可以對資料做任何操作,包括過濾、重組、修改等等。
  2 當資料流變化時,可以重新整理小部件。
    Stream是一種訂閱者模式,當資料發生變化時,通知訂閱者發生改變,重新構建小部件,重新整理UI。

  • 使用過程:

  1.建立StreamController,管理流。單訂閱只能新增一個listen,而多訂閱則無限制
  2.透過stream的listen方法監聽資料變化,並獲取StreamSubscription
  3.透過StreamSink往Stream中新增資料,如果不使用Controller的Stream,而是透過構造方法自建Stream的話,則需要透過StreamSink的addStream將自定義Stream繫結到Controller上。
  4.在dispose方法中取消訂閱,關閉流

  • EventBus,在實際開發中,我們很少直接使用Stream,一般都是透過三方庫去簡化操作,比如Event_Bus庫。主要作用是各元件間的通訊,能有效的分離事件傳送方和接收方(解耦),類似通知。

  EventBus是全域性事件匯流排,底層透過Stream來實現。EventBus物件初始化實際上初始化了一個_streamController物件,而這個物件是透過StreamController的broadcast(sync: sync)方法初始化的。EventBus可以實現不同頁面的跨層訪問,透過Stream的機制來實現不同widget之間的狀態共享
  使用方法如下:
    1.在pubsec.yaml檔案匯入依賴:event_bus: ^1.1.0
    2.初始化EventBus(傳送端),建立一個全域性的 EventBus,通常每個應用只有一個事件匯流排,但如果需要多個事件匯流排的話可以在初始化時設定 sync = false;
    3.監聽相應的響應事件(接收端,可以放在initState()中),可以透過 on(event).listen() 來監聽;其中若 on() 可以監聽所有事件也可以監聽固定的事件,區別是是否限制當前廣播;
    4.銷燬event_bus物件(接收端,放在dispose()中), 為了防止記憶體洩漏,一般在應用銷燬時都需要對 EventBus 進行銷燬;
    5.發出事件(傳送端) eventBus.fire(傳遞的資料) _event.cancel(); _eventBus.destroy();

------------------------------------------------------------示例程式碼--------------------------------------------------------------------

//2.首先建立一個全域性的 EventBus
EventBus _eventBus = EventBus();

void main(){
  runApp(MaterialApp(
    home: streamScaffold()
  ));
}

class streamScaffold extends StatelessWidget{
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: streamClass(),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.navigate_next,size: 30,color: Colors.blueGrey),
        onPressed: (){
          Navigator.of(context).push(MaterialPageRoute(
            builder: (context) => test1Page(),
          ));
        },
      ),
    );
  }
}

class test1Page extends StatelessWidget{
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("第二個頁面"),
      ),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.navigate_next,size: 30,color: Colors.blueGrey),
        onPressed: (){
          Navigator.of(context).push(MaterialPageRoute(
            builder: (context) => test2Page(),
          ));
        },
      ),
    );
  }
}

class test2Page extends StatelessWidget{
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("第三個頁面"),
      ),
      body: Center(
        child: TextButton(
          child: Text("更改首頁文字"),
          onPressed: (){
            // 5.發出事件(傳送端)  eventBus.fire(傳遞的資料)
            _eventBus.fire("來自第三頁的文字問候");
          },
        ),
      ),
    );
  }
}


class streamClass extends StatefulWidget{
  @override
  State<StatefulWidget> createState() => _streamState();
}

class _streamState extends State<streamClass>{

  late StreamController _streamCtrl;

  //Stream的訂閱物件
  late StreamSubscription _subscription;

  var _content = "初始內容";

  var _eventBusContent = "";

  late StreamSubscription _event;

  @override
  void initState() {
    // TODO: implement initState
    super.initState();

    //1.建立StreamController
    // _streamCtrl = StreamController(); //單訂閱
    _streamCtrl = StreamController.broadcast();  //廣播訂閱(多訂閱)

    /*如果是直接使用StreamController中的Stream的話,比較簡單,直接用就行,如果是自定義Strean的話則多了非同步建立和繫結的步驟
    //透過構造方法建立Stream
    Stream stream = Stream.fromFuture(
        Future.delayed(const Duration(milliseconds: 500)).then((value) {
          return '我是Stream的future執行結果';
        }));

    //新增到StreamController中管理
    _streamCtrl.sink.addStream(stream);

    //2.監聽用做新增事件的入口
    _subscription = stream.listen((event) {
    });
  * */

    //2.監聽用做新增事件的入口
    _subscription = _streamCtrl.stream.listen((event) {
      print(event);
      // setState(() {
      //   _content = event;
      // });
    });

    //如果是單訂閱的話,下面這段程式碼就會報錯,因為上面已經有了個listen,無法在用其他的訂閱了
    // _streamCtrl.stream.listen((event) {
    //   print(event);
    //   // setState(() {
    //   //   _content = event;
    //   // });
    // });


    //eventBus的使用
    //3.監聽相應的響應事件(接收端,可以放在initState()中),可以透過 on(event).listen() 來監聽;拿到訂閱者方便管理
    _event = _eventBus.on().listen((event) {
      print(event);
      setState(() {
        _eventBusContent = event;
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          // Text(_content),
          //如果我們希望值變化後重新整理某個Widget的話,最好使用StreamBuilder構造器,每次值改變的時候都會引起StreamBuilder的監聽,StreamBuilder重建並重新整理。這樣不用每次都透過setState重構整個item
          StreamBuilder(
              stream:_streamCtrl.stream, //...需要監聽的stream...
              initialData: "初始內容",//初始資料,儘量不要填null,初始化時會將該值作為snapshot的值傳過去初始化
              builder: (BuildContext context, AsyncSnapshot snapshot) { //AsyncSnapShot是快照的意思,儲存著此刻最新的事件。
                if (snapshot.hasData){ //...基於snapshot.hasData返回的控制元件
                  // 根據 snapshot 的資料處理返回
                  var data = snapshot.data;
                  print(data);
                  return Text(data);
                }
                return Text("空內容");//...沒有資料的時候返回的控制元件
              }
          ),
          SizedBox(height: 20),
          TextButton(
              onPressed: (){
                //3.往Stream中新增資料
                _streamCtrl.sink.add("new Value");
              },
              child: Text("變更")
          ),

          SizedBox(height: 20),

          Text(_eventBusContent)
        ],
      )
    );
  }

  @override
  void dispose() {
    // TODO: implement dispose
    super.dispose();
    //4.在dispose方法中取消訂閱,關閉流
    _subscription.cancel();
    _streamCtrl.close();

    //4.銷燬event_bus物件(接收端,放在dispose()中)  _event.dispose();
    _event.cancel();
    _eventBus.destroy();
  }

}

25、混合開發、Flutter 是如何與原生Android、iOS進行通訊的?

分兩種情況:

  • 專案直接由Flutter開發,但有時候需要用到一些原生的能力相機、相簿、位置資訊、Map,這個時候分兩種情況

  *像相機、相簿、定位這種功能,pub.dev上有一些比較好的第三方外掛,我們可以直接使用三方庫來操作,這樣就不需要寫原生程式碼了

  *但有些原生能力,Flutter並沒有提供對應的api,更沒有很好的第三方外掛或者某些SDK不支援flutter但專案又必須要用,我們可以透過platform channels(平臺通道)來獲取資訊。平臺通道允許Flutter程式碼與原生平臺程式碼(如Java、Kotlin、Objective-C、Swift)相互呼叫,從而實現Flutter與原生程式碼之間的資料和功能互動。 也就是在Dart中呼叫原生程式碼(i0S、Android)

    使用比較簡單:
      1、在Flutter端透過MethodChannel建立一個通道,Flutter端和原生平臺會藉助這個通道去通訊:MethodChannel("gao.com/battery");這裡需要傳入一個name,該name是區分多個通訊的名稱,每個通訊通道的名稱都要唯一,一般是域名+功能
      2、在Flutter端,直接透過建立好的通道給原生平臺發訊息,要求它執行某個程式碼並等待結果返回platform.invokeMethod("getBatteryInfo"); 這裡需要傳入原生平臺中的要執行方法名稱
      3.原生平臺實現方法並作出反應:
        iOS端:用Xcode開啟Flutter專案中的iOS程式碼,在Appdelegate檔案的didFinishLaunchingWithOptions方法中透過FlutterMethodChannel監聽事件請求並做出響應:
          1.獲取FlutterViewController(是應用程式的預設Controller),用於設定MethodChannel的binaryMessenger
          2.獲取FlutterMethodChannel(方法通道),注意:這裡需要根據我們建立通道時的名稱來獲取
          3.透過setMethodCallHandler方法監聽方法呼叫並作出響應(有兩個引數:call是用來獲取方法呼叫者的相關資訊,result是用於方法響應的資料返回)
        Android端:安卓端的實現思路和iOS一致。只不過安卓是在MainActivity檔案的configureFlutterEngine方法中透過MethodChannel做出響應;
          1.獲取MethodChannel(方法通道),注意:這裡需要根據我們建立通道時的名稱來獲取
          2.透過setMethodCallHandler方法監聽方法呼叫並作出響應(有兩個引數:call是用來獲取方法呼叫者的相關資訊,result是用於方法響應的資料返回)

      如需攜帶引數傳遞給原生端,可以直接拼接在方法呼叫時方法名的後面
        platform.invokeMethod('yourMethod', [{"fileName": "fName"}]);
      原生端獲取引數直接透過setMethodCallHandler方法中的call引數的arguments屬性來獲取:
        call.arguments

  • 專案原本就有原生程式碼(i0S、Android )編寫,但新模組打算用Flutter開發節約成本,或者原有模組由Flutter重構。也就是在原生程式碼(iOS、Android)呼叫Dart

  對於需要進行混合開發的原有專案,Flutter可以作為一個庫或者模組,繼承進現有專案中。其使用流程也比較簡單,跟iOS中使用第三方庫差不多,使用方法如下:
    1.首先透過命令列建立Flutter Module(注意這裡是建立模組而不是專案):【flutter create --template module 模組名稱】。建立完成後,該模組和普通的Flutter專案一樣,可以透過Android Studio或VSCode開啟、開發、執行;
    2.模組開發完成之後,該怎麼嵌入移動端呢?跟三方庫一樣,使用 CocoaPods 依賴管理和已安裝的 Flutter SDK在Posfile檔案中設定Flutter模組的路徑這個路徑是相對Podfile檔案來說的相對路徑

               # Uncomment the next line to define a global platform for your project
               # platform :ios, '9.0'

               # 新增模組所在路徑
               flutter_application_path = '../my_flutter'
               load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')

               target 'ios_my_test' do
                  # Comment the next line if you don't want to use dynamic frameworks
                  use_frameworks!

                 # 安裝Flutter模組
                 install_all_flutter_pods(flutter_application_path)

                 # Pods for ios_my_test
              end

    3.重新執行安裝CocoaPods的依賴:pod install
    4.將Flutter模組匯入到iOS專案中後,接下來就是使用了。一般作為一個模組使用的話,就是由某個控制器push或者present出來Flutter模組頁面。為了在既有的iOS應用中展示Flutter頁面,需要啟動 Flutter Engine和 FlutterViewController。
    5.在應用啟動的 app delegate 中建立一個 FlutterEngine並在didFinishLaunchingWithOptions方法中啟動Flutter引擎,並作為屬性暴露給外界。(也可以省略預先建立的 FlutterEngine,在建立Controller是引擎傳空。但這樣可能會出現明顯的延遲,導致顯示Flutter頁面時會暫時空白)

                  class AppDelegate: UIResponder, UIApplicationDelegate {
                      // 1.建立一個FlutterEngine物件
                      lazy var flutterEngine = FlutterEngine(name: "my flutter engine")

                     func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
                        // 2.啟動flutterEngine
                        flutterEngine.run()
                        return true
                    }
                 }

    6.在需要彈出Flutter模組的地方直接建立FlutterController,彈出Flutter頁面

               @objc func showFlutter() {
                  // 建立FlutterViewController物件(需要先獲取flutterEngine)
                  let flutterEngine = (UIApplication.shared.delegate as! AppDelegate).flutterEngine;
                  let flutterViewController = FlutterViewController(engine: flutterEngine, nibName: nil, bundle: nil);
                  navigationController?.pushViewController(flutterViewController, animated: true);
              }

    7.將Flutter模組嵌入原生專案中,尤其是iOS的原生專案,如果想保留flutter的Hot Reload的優勢,可以透過flutter attach 除錯 :
      1、利用Xcode啟動以及嵌入好Fluttre模組的原生專案
      2、Android Studio中開啟終端輸入 flutter attach命令,如果有多個應用或者多個裝置的話,可以透過--app-id是指定哪一個應用程式,透過-d是指定連線哪一個裝置
        flutter attach --app-id com.coderwhy.ios-my-test -d 3D7A877C-B0DD-4871-8D6E-0C5263B986CD
      3、這樣在 Android Studio 中修改 flutter 模組程式碼 ,在終端中執行 r 或者 R,在模擬器中就能看到最新的樣式了 。

------------------------------------------------------------示例程式碼--------------------------------------------------------------------

iOS端程式碼(AppDelegate.swift)
@objc
class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { // 1.獲取FlutterViewController(是應用程式的預設Controller) 用於設定MethodChannel的binaryMessenger let controller : FlutterViewController = window?.rootViewController as! FlutterViewController // 2.獲取MethodChannel(方法通道) 根據我們建立通道時的名稱來獲取 let batteryChannel = FlutterMethodChannel(name: "flutter_test_project/getBattery", binaryMessenger: controller.binaryMessenger) // 3.透過方法通道的setMethodCallHandler方法監聽方法呼叫並作出響應 (有兩個引數:call是用來獲取方法呼叫者的相關資訊,result是用於方法響應的資料返回) batteryChannel.setMethodCallHandler({ [weak self] (call: FlutterMethodCall, result: FlutterResult) -> Void in // call.arguments // 3.1.判斷是否是getBatteryInfo的呼叫,告知Flutter端沒有實現對應的方法 guard call.method == "getPhontBatteryInfo" else { result(FlutterMethodNotImplemented) return } // 3.2.如果呼叫的是getBatteryInfo的方法, 那麼透過封裝的另外一個方法實現回撥 self?.receiveBatteryLevel(result: result) }) } //純原生程式碼,用於獲取電池資訊 private func receiveBatteryLevel(result: FlutterResult) { // 1.iOS中獲取資訊的方式 let device = UIDevice.current device.isBatteryMonitoringEnabled = true // 2.如果沒有獲取到,那麼返回給Flutter端一個異常 if device.batteryState == UIDevice.BatteryState.unknown { result(FlutterError(code: "UNAVAILABLE", message: "Battery info unavailable", details: nil)) } else { // 3.透過result將結果回撥給Flutter端 result(Int(device.batteryLevel * 100)) } } }
Flutter端程式碼:
class _channelState extends State{ late MethodChannel _channel; String _currentBattery = "當前電量為:0"; @override void initState() { super.initState(); //1、在Flutter端透過MethodChannel建立一個通道,Flutter端和原生平臺會藉助這個通道去通訊:MethodChannel("gao.com/battery"); _channel = MethodChannel("flutter_test_project/getBattery"); } @override Widget build(BuildContext context){ return Scaffold( appBar: AppBar(title: Text(_currentBattery)), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text(_currentBattery), SizedBox(height: 20), TextButton( onPressed: ()async{ //2.在Flutter端,直接透過建立好的通道給原生平臺發訊息,要求它執行某個程式碼並等待結果返回: final String result = await _channel.invokeMethod("getPhontBatteryInfo",["傳遞引數1","傳遞引數2"]); setState(() { _currentBattery = result; }); }, child: Text("獲取電量資訊")), ], ), ), ); } }

26、測試
Flutter官方對Flutter應用測試型別做了三個階段劃分,分別為Unit(單元)測試、Widget(元件)測試、Integration(整合)測試

  • 單元測試:單元測試通常是測試一個函式或者類,這個函式或者類被稱之為是一個單元。比如我們需要檢測某個類的初始化是否正確,執行某個方法的結果是否正確?其邏輯就是在test方法裡執行方法或者類,判斷其執行結果與理想結果是否一致

  示例類:

class Counter {
  int value = 0;

  void increment() => value++;
  void decrement() => value--;
}

  單元測試如下:
  我們在test目錄下(注意:不是lib目錄下),建立一個測試檔案:counter_test.dart(測試檔案通常以xx _test.dart 命名)

import 'package:flutter_test/flutter_test.dart';
import 'package:test_demo/counter.dart';

void main() {
  test("Counter Class test", () {
    // 1.建立Counter並且執行操作
    final counter = Counter();
    counter.increment();
    // 2.透過expect來監測結果正確與否  
    expect(counter.value, 1);  //expect方法的作用是執行引數1的程式碼,然後和引數2的預期結果做比對,從而達到測試目的
  });
}

  如果對同一個類或函式有多個測試,我們希望它們關聯在一起進行測試,可以使用group

void main() {
  group("Counter Test", () {
      test("Counter Default Value", () {
          expect(Counter().value, 0);
      });

      test("Counter Increment test", () {
          final counter = Counter();
          counter.increment();
          expect(counter.value, 1);
      });

      test("Counter Decrement test", () {
          final counter = Counter();
          counter.decrement();
          expect(counter.value, -1);
      });
  });
}
  • widget 測試:Widget測試主要是針對某一個封裝的Widget進行單獨測試。(我們要開發一個UI介面,需要透過組合其它Widget來實現,Flutter中,一切都是Widget!)其原理是,在testWidgets方法中透過建立一個待測試的Widget,然後檢視其組成控制元件是否正確,流程如下:

  1.建立一個 testWidgets 方法
  2.用 tester.pumpWidget建立一個待測試Widget
  3.用 finder 方法來在 Widget tree 中查詢Widget中對應的子控制元件數量
  4.用 expect 來測試子控制元件出現的結果是否正確
    findsOneWidget 只有一個對應的 Widget
    findsNothing 沒有找到對應的 Widget
    findsWidgets 找到一個或一個以上對應的 Widget
    findsNWidgets 找到特定數量對應的Widget

  示例Widget

import 'package:flutter/material.dart';

class HYKeywords extends StatelessWidget {
  final List<String> keywords;
  HYKeywords(this.keywords);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ListView(
        children: keywords.map((key) {
          return ListTile(
            leading: Icon(Icons.people),
            title: Text(key),
          );
        }).toList(),
      ),
    );
  }
}

  測試程式碼:

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:test_demo/keywords.dart';

void main() {
  testWidgets("KeywordWidget Test", (WidgetTester tester) async {
    await tester.pumpWidget(MaterialApp(title: "demo", home: HYKeywords(["abc", "cba", "nba"]),));

    final abcText = find.text("abc"); //查詢內容為abc的Text控制元件
    final cbaText = find.text("cba");//查詢內容為cba的Text控制元件
    final icons = find.byIcon(Icons.people);//查詢內容為Icons.people的Icon控制元件

    expect(abcText, findsOneWidget); //內容為abc的Text控制元件只找到一個
    expect(cbaText, findsOneWidget); //內容為cba的Text控制元件只找到一個
    expect(icons, findsNWidgets(3)); //內容為Icons.people的Text控制元件只找到三個
  });
)
  • 整合測試: 測試一個完整的應用程式或應用程式的很大一部分。通常,整合測試可以在真實裝置或OS模擬器上執行,例如iOS Simulator或Android Emulator。模擬使用者的點選、輸入操作,從而完成功能的驗證。

  使用流程如下:
    1、建立一個可以執行在模擬器或者真實裝置的應用程式。比如預設建立的Demo工程,其中包括懸浮按鈕和中間的文字顯示。
    2、給測試中可能會用到的元件新增Key(ValueKey),在這裡我們給 Text 和 FloatingActionButton 新增了 ValueKey 以便在測試時識別這些特點的 Widgets。
    3、在 pubspec.yaml 中加入整合測試中要用到 flutter_driver,同時,也新增 test ,因為要用到這裡面的方法和斷言。(flutter_driver並不是建立專案標配的,需要你額外安裝。)

    dev_dependencies:
        flutter_driver:
           sdk: flutter
        test: any

    4、建立和 lib 檔案同級的資料夾 test_driver(資料夾名稱必須是test_driver)。
    5、在driver資料夾下建立兩個檔案:一個是建立指令化的 Flutter 應用程式,使我們能 "執行" 這個app,並記錄執行的(app.dart),另一個用於寫測試來判斷app 是不是按預期執行(app_test.dart),目錄如下:

    lib/
         main.dart
    test_driver/
        app.dart
        app_test.dart

    6、在 app.dart中編寫安裝應用程式碼,啟動帶測試的應用程式。寫法固定如下:

import 'package:flutter_driver/driver_extension.dart';
import 'package:test_demo/main.dart' as app;

void main() {
  // 開啟Flutter Driver的擴充套件
  enableFlutterDriverExtension();

  // 手動呼叫main函式, 啟動應用程式
  app.main();
}

    7、在app_test.dart中編寫整合測試程式碼,使用Flutter Driver API告訴應用程式執行什麼操作,然後驗證應用程式是否執行了此操作。這包含了四個步驟:
      建立 SerializableFinders 定位指定元件
      在 setUpAll() 函式中執行測試案例前,先與待測應用建立連線
      測試一些重要的流程
      完成測試後,在 teardownAll() 函式中與待測應用斷開連線

// Imports the Flutter Driver API
import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart';

void main() {
  group('Counter App', () {
    // 透過 Finders 找到對應的 Widgets
    final counterTextFinder = find.byValueKey('counter');
    final buttonFinder = find.byValueKey('increment');

    FlutterDriver driver;

    // 初始化操作  測試程式碼執行之前呼叫
    setUpAll(() async {
      driver = await FlutterDriver.connect();// 連線 Flutter driver 
    });

    // 測試結束操作  測試程式碼執行完成後呼叫
    tearDownAll(() async {
      if (driver != null) {
        driver.close();// 當測試完成斷開連線
      }
    });

    test('starts at 0', () async {
      // 用 `driver.getText` 來判斷 counter 初始化是 0
      expect(await driver.getText(counterTextFinder), "0");
    });

     // 編寫測試程式碼
    test('increments the counter', () async {
      // 首先,點選按鈕
      await driver.tap(buttonFinder);

      // 然後,判斷是否增加了 1
      expect(await driver.getText(counterTextFinder), "1");
    });
  });
}

    8、 執行整合測試
      啟動安卓模擬器或者 iOS 模擬器,或者直接把 iOS 或 Android 真機連線到電腦上。接著,在專案的根資料夾下執行下面的命令:flutter drive --target=test_driver/app.dart 這個指令的作用:建立 --target 目標應用並且把它安裝在模擬器或真機中啟動應用程式執行位於 test_driver/ 資料夾下的 app_test.dart 測試套件
      執行結果:我們會發現正常執行,並且結果app中的FloatingActionButton自動被點選了一次。

相關文章