iT邦幫忙

2024 iThome 鐵人賽

DAY 8
0
Mobile Development

Flutter 開發實戰 - 30 天逃離新手村系列 第 8

Day 8 Dart 深入探索 Flutter 常用特性

  • 分享至 

  • xImage
  •  

檔案和匯入

注意我們的 Flutter 專案結構,會注意到有個檔案叫 lib/main.dart 裡面包含了一個類別 MyApp。這裡要提到的是,不像一些其他程式語言,檔案名稱須對照類別名稱。Dart 並沒有強制需要這麼做。

另外,在 Dart 將多個相關的類別,Enum,函式放在同一個檔案構成一個函式庫是很常見的做法。這種做法可以通過將某些類別開頭加入 _ 底線達成 private 私有類別,而其他類別可以被外部存取進而實現封裝。

我們後續探討 Widget 時,會很常看到兩個類別在同一個檔案例如一個公開的 StatefulWidget 類別和一個私有的 State 類別。

當然我們不會在單一檔案中建構整個應用程式 ,如此一來程式碼會變得難以維護,妨礙封裝,甚至造成 IDE 效能問題。因此我們需要一個從其他檔案引用的方式。通過 import 可以完成我們的需求。

幾乎任何語言都支援匯入的方式,從其他檔案匯入類別,packages,plugins 等需要的功能。如果觀察 main.dart 會看到

import 'package:flutter/material.dart';

在這個例子,material.dart 檔案被匯入,在這個檔案中的類別和函式你都可以使用。這個檔案包含很多基本 Flutter 支援的功能,大部分的 Flutter 應用程式都會匯入。

本節我們已經了解了 Dart 類別的結構,包含類別成員,方法。然後,我們了解了不同建構子實例化物件。還討論了一些特殊使用類別的方式如抽象類別,介面,Mixins。最後學習了如何分享程式碼到不同檔案。

Enum 列舉

列舉是一種用來表示一組固定常數的資料類型。Dart 也一樣,通過 enum 關鍵字可以定義 enum 。首先,基本的 enum 使用:

enum PersonType { student, employee }
enum Season { spring, summer, autumn, winter }

列舉提供了一種結構化且易讀的方式來表示固定選項。每個列舉值都有一個對應的索引,從 0 開始。你可以把它想成是一組常數變數的集合,本來需要各自宣告變數和賦予常數值,現在我們可以直接使用 PersonType.student 來賦值。主要可以讓程式增加可讀性和好維護,會比使用字串或數字詮釋易懂。

下面我們來看看其如何運作,首先加入一個欄位到之前定義的 Person 類別

enum PersonType { student, employee }

class Person {
  String name;
  PersonType type;

  Person(this.name, this.type);
}

void main() {
  print(PersonType.values); // [PersonType.student, PersonType.employee]
  Person person = Person("andyyou", PersonType.student);
  print(person.type); // 輸出: PersonType.student
  print(person.type.index); // 輸出: 0,因為 `student` 是列舉中的第一個值
  // print(describeEnum(PersonType.employee));
  // employee
}

當我們呼叫 PersonType.values 時會如註解一樣輸出,還可以呼叫其 index。一般來說,我們不會依靠索引,而是直接使用 PersonType.employee 這樣來表示值。

另外在 Flutter 中支援 describeEnum 方法來獲取列舉值的字符串表示 employee

import 'package:flutter/foundation.dart';

enum PersonType { student, employee }
print(describeEnum(PersonType.employee)); // 輸出: employee

當需要儲存在資料庫時,通常建議使用字串的方式

person.type.name;
// 或者
person.type.toString().split('.').last();

要從字符串轉回列舉值,可以使用以下方法:

PersonType stringToPersonType(String typeString) {
  return PersonType.values.firstWhere((type) => type.toString().split('.').last == typeString, orElse: () => throw Exception('Unknown type string: $typeString'));
}

常見使用 enum 的情形如 switch 條件式,因為如果 switch 的條件處理沒有包含全部項目的話會觸發警告。這非常適合於已經寫好了處理所有不同 enum 值的程式,然後又增加另一個值的情況 —— 編譯器將警告你,你沒有完整的處理全部的選項。

swtich (person.type) {
  case PersonType.student:
  	print('Learn');
  	break;
  case PersonType.employee:
  	print('Work');
  	break;
}

如果我們之後加入新的類型如 PersonType.retired 那麼 switch 那邊就會有警告。

使用泛型 Generics

<> 語法是用來指定型別。如果你看過第二章節的 List 和 Map 範例,注意到我們並沒有指定它們應該包含的型別。這是因為型別的資訊是可選的,Dart 可以基於元素推斷型別。

那什麼時候以及為什麼要使用泛型?

泛型可以幫助開發者維護和控制集合的行為。當我們的集合沒有使用型別時,加入正確的元素是我們的責任,但這確實可能導致 Bug 如果我們加入錯誤的型別導致錯誤的推斷。

例如下面範例,我們有一個 placeNames 變數。我們希望是一個名稱列表,但不幸的,在沒有使用泛型的情況下我們可以加入任何值,如果不小心加入 number。那麼就容易導致錯誤。

main() {
  List placeNames = ["Middlesbrough", "New York"];
  placeNames.add(1);
  print("Place names: $placeNames");
}

如果此時我們使用泛型,那麼程式碼在編譯時期就會出錯,進而提早發現錯誤

main() {
  List<String> placeNames = ['Taipei', 'Changhua'];
  placeNames.add(1); // 錯誤
}

泛型和 Dart 字面量

在 List 和 Map 的範例中,我們使用了 []{} 語法,它們就是字面量的格式。泛型有一個取代前面語法的方式,我們可以在初始化的時候把 <Type> 加到 []{} 的前面:

main() {
  var placeNames = <String>['Middlesbrough', 'New York'];
  var landmarks = <String, String> {
    'Middlesbrough': 'Transporter bridge',
    'New York': 'Statue of Liberty'
  }
}

上面的例子看起來似乎在 [] 前面加入泛型有點多餘,因為 Dart 會自己推斷型別,然而在一些情況下,這還是很重要的,例如初始的集合是一個空集合

var stringArray = <String>[];

如果我們沒有指定型別,那麼它可以加入任何資料,進而推斷錯誤的型別。

可為空泛型

如同我們在前面學習的 Null 安全,如果變數可以為 null 那麼必須在變數宣告。在泛型也一樣,如果集合允許為 null

例如假設我們的 landmarks Map 我們允許一些地點沒有地標。我們可以宣告,然後當我們存取 Map 實例時,就有可能為 null

main() {
  var landmarks = <String, String?> {
    'Middlesbrough': 'Transporter bridge',
    'New York': 'Statue of Liberty',
    'Barnmouth': null,
  }
}

我們指定了值可以為 null然後加入了新的資料。到此,我們已經學習了關於型別安全的部分。

非同步

Dart 是單執行緒程式語言,意思是所有程式碼都在同一個執行緒執行類似於 Nodejs。簡單說,這意味著程式碼如果執行長時間的操作可能會阻塞執行緒例如 I/O 操作或者 HTTP 請求。例如一個很慢的 HTTP 請求卡住操作,在使用者持續嘗試操作介面時可能會造成問題。應用程式會看起來像是不動了,不回應了。

雖然 Dart 是單執行緒,但可以執行非同步操作。這允許程式碼觸發一個操作,然後繼續執行其他任務,等操作完成就回到這個操作。要呈現這些非同步操作,Dart 使用 Future 物件結合 async await 關鍵字。

Future

當我們的程式呼叫一個需要長時間執行的任務時,我們不希望它阻塞其他如 UI 操作,此時,我們可以使用 async 註記該方法為非同步。這會讓呼叫方法的地方了解該操作會花費長時間,且在等待結果時不應該阻塞其他執行。即呼叫了該方法之後繼續執行其他程式碼。

但我們確實需要取得這個長時間執行的結果,因此我們需要它處理完畢時回到呼叫的地方。為此,我們需要指定當回應的時候回到剛剛呼叫的地方,這時我們要使用 await 關鍵字。asyncawait 的差異是 async 是宣告該方法為非同步,await 用於呼叫方法等待回應的地方。

假設我們已經使用 async 宣告方法為非同步,然後我們用 await 指定了完成任務時回到的地方,但這個方法會回傳什麼回來呢?在 Dart 中 Future<ResultType> 物件表示會在未來的某個時間點會提供值。可以用來宣告方法回傳未來的結果 - 表示一個方法回傳 Future<ResultType> 物件將不會立刻取得結果,而是在之後的某廣告時間點。類似於 JavaScript 的 Promise。

為了理解非同步,讓我們從一個簡單的同步程式開始:

import 'dart:io';

void longRunningOperation() {
  for (int i = 0; i < 5; i++) {
    sleep(Duration(seconds: 1));
    print('長時間操作: $i');
  }
}
main() {
  print('開始長時間操作');
  longRunningOperation();
  print('繼續執行主程式');
  for (int i = 10; i < 15; i++) {
    sleep(Duration(seconds: 1));
    print('主程式: $i');
  }
  print('主程式結束');
}

這裡我們有一個 main 函式,呼叫了一個長時間操作的函式。我們使用 sleep() 來暫停執行,這個函式需要匯入 dart:io 套件。

如果你執行上面程式碼會得到結果如下:

開始長時間操作
長時間操作: 0
長時間操作: 1
長時間操作: 2
長時間操作: 3
長時間操作: 4
繼續執行主程式
主程式: 10
主程式: 11
主程式: 12
主程式: 13
主程式: 14
主程式結束

注意到 longRunningOperation() 會阻塞 main 的執行,這就是一般的同步執行,這種情形在實際應用程式執行的狀況下,UI 會被卡住導致使用者體驗不佳。

現在,讓我們將其改為非同步版本

import 'dart:io';
import 'dart:async';

Future<void> longRunningOperation() async {
  for (int i = 0; i < 5; i++) {
    sleep(Duration(seconds: 1));
    print('長時間操作: $i');
  }
}
main() {
  print('開始長時間操作');
  longRunningOperation(); // 注意到這裡沒有使用 await
  print('繼續執行主程式');
  for (int i = 10; i < 15; i++) {
    sleep(Duration(seconds: 1));
    print('主程式: $i');
  }
  print('主程式結束');
}

現在我們把 longRunningOperation() 變成非同步了,並且回傳型別使用 Future,當我們再次執行會看到結果:

開始長時間操作
長時間操作: 0
長時間操作: 1
長時間操作: 2
長時間操作: 3
長時間操作: 4
繼續執行主程式
主程式: 10
主程式: 11
主程式: 12
主程式: 13
主程式: 14
主程式結束

結果依舊沒變!!雖然我們將 longRunningOperation() 加上了 async ,但我們依然使用了會同步的 sleep() 函式,執行緒還是會被塞住。我們得使用另一個非同步,但效果和 sleep 類似的方法叫 Future.delayed() ,讓我們在更新一次範例:

import 'dart:io';
import 'dart:async';

Future<void> longRunningOperation() async {
  for (int i = 0; i < 5; i++) {
    await Future.delayed(Duration(seconds: 1));
    print('長時間操作: $i');
  }
}
void main() { ... }

現在我們使用了 Future.deplayed ,它是非同步的了。我們希望繼續執行其他程式,然後當加入 await 的地方完成之後回來。現在執行緒被解放了,但當該函式完成時會回到 await 。再次執行的結果如下:

開始長時間操作
繼續執行主程式
主程式: 10
主程式: 11
主程式: 12
主程式: 13
主程式: 14
主程式結束
長時間操作: 0
長時間操作: 1
長時間操作: 2
長時間操作: 3
長時間操作: 4

我們不再只有同步的程式碼了(即程式碼完全依序執行),如同我們上面完成的,執行順序變了。在上面範例,變化發生在 longRunningOperation() 呼叫的時候我們使用了 await 搭配非同步函式 Future.delayed()longRunningOperation() 函式會在該位置暫停,然後在延遲 1 秒操作完成並回應時恢復繼續執行。

在延遲之後, main() 函式已經繼續執行;並且沒有等待 longRunningOpertaion() 執行完成, longRunningOperation() 會在 main 執行完之後繼續。如果我們將 main 也變成非同步並 await longRunningOperation() ,此時 main 將會被暫停等到其執行完畢就跟一開始的同步版本一樣。嘗試另一個實驗在 mainsleep 換成 Future.delayed

main() async {
  print('開始長時間操作');
  
  longRunningOperation();
  
  print('繼續執行主程式');
  
  for(int i = 10; i < 15; i++) {
    await Future.delayed(Duration(seconds: 1));
    print('主程式: $i');
  }
  
  print('主程式結束');
}

結果

開始長時間操作
繼續執行主程式
長時間操作: 0
主程式: 10
長時間操作: 1
主程式: 11
長時間操作: 2
主程式: 12
長時間操作: 3
主程式: 13
長時間操作: 4
主程式: 14
主程式結束

要理解這個輸出結果,我們需要了解 Dart 在同一個執行緒執行 2 個非同步方法。兩個函式都是非同步,但這並不是意味著平行。Dart 一次執行一個操作;因此只要一個操作在執行中就不會被其他程式碼中斷。這個執行是由 Dart 的 Event Loop 控制的,管理 Future 和非同步程式碼。

因此在我們範例中,longRunningOperation 函式被執行,當碰到 Future.delayed() 呼叫時就釋放執行緒的控制,執行緒可以接著執行 main 直到 main 的操作又遇到 Future.delayed ,換 main 釋放控制回到 longRunningOperation 反覆執行。

雖然看似為平行,Dart 也確實有平行的操作 - 即多個程式碼同時執行。但這裡的不屬於平行。要真的實現平行操作需要使用 Isolates。

  • async 關鍵字用於標記一個函式為非同步函式。這允許在函式內使用 await 關鍵字,並且該函式會自動返回一個 Future 物件。
  • await 關鍵字用於等待一個 Future 完成並取得其結果。它只能在 async 函式內使用,可以讓非同步程式碼看起來像同步程式碼,提高可讀性。
  • Future 是一個表示非同步操作結果的物件。它代表了一個尚未完成的計算或操作,這個操作將在未來的某個時間點完成並提供一個值(或錯誤)。
  • Future.delayed 是一個常用的 Future 建構子,用於建立一個延遲執行的非同步操作。它接受一個 Duration 參數來指定延遲時間,以及一個可選的回調函式。

這裡我們的範例是為了強調 asyncawaitFuture(如 Future.delayed())須組合使用才能實現非同步,單純光靠 async await 並無法非同步。也希望帶出正確的理解。

Dart Isolates

你可能已經知道 Dart 可以真的達成平行操作。Dart Isolate 就是設計來達成這個目的的。每一個 Dart 應用程式至少都是由一個 Isolate 實例組成的 - 核心 Isolate 實例執行全部應用程式的程式碼,因此要平行執行程式碼必須要建立一個 Isolate 實例,讓它和核心 Isolate 實例平行運作。

Isolate 可以想成是一種執行緒。但他們彼此無法分享,就如同其名稱。表示它們互相不分享記憶體,所以我們不需要使用其他鎖定和其他執行緒同步的技術。Isolate 的溝通需要傳送和接收 - 需要交換資訊。

讓我們使用 Isolate 修改上面的實作

import 'dart:isolate';

Future<void> longRunningOperation(String message) async {
  for (int i = 0; i < 5; i++) {
    await Future.delayed(Duration(seconds: 1));
    print("$message: $i");
  }
}

void main() async {
  print("開始長時間操作");
  Isolate.spawn(longRunningOperation, "哈囉");
  print('繼續執行主程式');
  for (int i = 10; i < 15; i++) {
    await Future.delayed(Duration(seconds: 1));
    print("主程式: $i");
  }
  print("主程式結束");
}

如你所見,我們微調了程式碼。

  • longRunningOperation 函式還是一樣,但我們加入了 message,這個參數可以使用 Isolate 傳遞
  • 為了初始化 Isolate 開始執行,我們使用了 spawn() 傳入兩個參數,一個函式和參數
  • 上面我們還加入了 dart:isolate

執行程式碼會得到類似的輸出

開始長時間操作
繼續執行主程式
主程式: 10
哈囉: 0
主程式: 11
哈囉: 1
主程式: 12
哈囉: 2
主程式: 13
哈囉: 3
主程式: 14
主程式結束
哈囉: 4

兩個函式一樣是交錯運行,但這一次 mainlongRunningOperation() 先執行。前一個例子是執行緒在遇到 await Future.delayed() 之前不會釋放控制權,而 spawn 操作是建立一個獨立的非同步,讓 main 立刻可以執行到 Future.delayed() 。此外,請注意這裡沒有我們在前一個例子中看到的情況,即每個函數在 await 點將控制權交給另一個函數。這些實際上是兩個獨立運行分開的執行緒。

程式碼生成

為了儘量補充開發 Flutter 所需的一些知識,我們得再介紹一個概念 - 「程式碼生成」,這是是一種自動產生程式碼的技術。在 Dart 和 Flutter 中,我們經常使用它來減少重複性的工作,提高開發效率。

程式碼生成主要是透過 Dart 的能力實現的,利用了語言的註解系統 @ 和映射能力實現。開發者透過特定的註解標記,生成程式碼的類別或方法。生成的程式碼通常會被儲存在 .g.dart 檔案中,並透過 part 加入到原始檔案。

例如我們有一個 User 類別:

class User {
  final String name;
  final int age;

  User(this.name, this.age);
}

如果我們要將這個類別轉換成 JSON 格式,或從 JSON 格式轉回來,我們需要寫很多重複的程式碼:

class User {
  final String name;
  final int age;

  User(this.name, this.age);

  Map<String, dynamic> toJson() {
    return {
      'name': name,
      'age': age,
    };
  }

  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      json['name'] as String,
      json['age'] as int,
    );
  }
}

這樣的程式碼寫起來很繁瑣,而且容易出錯。

這時,我們可以使用 json_serializable 套件來自動生成這些程式碼。首先,我們需要在 pubspec.yaml 中加入:

dependencies:
  json_annotation: ^4.8.1

dev_dependencies:
  build_runner: ^2.4.6
  json_serializable: ^6.7.1

然後,我們修改 User 類別,加入一些特殊的註解:

import 'package:json_annotation/json_annotation.dart';

part 'user.g.dart';

@JsonSerializable()
class User {
  final String name;
  final int age;

  User(this.name, this.age);

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

然後,可以執行以下指令來生成程式碼:

$ flutter pub run build_runner build

總結

到此,我們學習了 Dart 如何結合物件導向,從而討論了 Dart 的類別包含繼承,抽象和 Mixin。更進一步學習了不同的類型的建構子如 Flutter 中會用到的具名建構子和工廠模式建構子。

這些知識、概念將協助我們更容易理解,閱讀關於 Flutter 開發相關的文件或範例。後續章節我們會開始探討 Flutter 的概念和一些底層原則。下一篇我們將從 Widget 開始,我們將立刻用到這些新知識。

重點筆記

  1. 一個類別能繼承幾個上級類別? 在 Dart 中,一個類別只能繼承自一個上級類別,即單一繼承。
  2. 如果你想創建一個從多個父類別繼承的新類別,該怎麼做? 雖然 Dart 不支持多重繼承,但可以使用 mixins 來實現從多個類別獲取功能。透過 with 關鍵字,你可以將一個或多個 mixins 混入到你的類別中。
  3. 描述多型(Polymorphism)並給出一個實用的例子。 多型是物件導向程式設計中的一個概念,指的是對象可以呈現出多種形態。例如,假設有一個動物(Animal)類別,狗(Dog)和貓(Cat)類別都繼承自動物類別。雖然每個子類別的具體行為(例如叫聲)可能不同,但它們都可以被當作動物對待。這在程式設計中非常有用,因為你可以編寫處理動物類別的程式碼,同時適用於所有的子類別。
  4. 解釋實例欄位/方法和靜態欄位/方法之間的區別。 實例欄位和方法屬於類別的一個實例,每個對象的實例欄位都是獨立的。靜態欄位和方法則屬於類別本身,被所有該類別的實例共享。
  5. 用下劃線開頭命名類別名稱、欄位名或方法名的目的是什麼? 在 Dart 中,以下劃線開頭的名稱表示私有性。這意味著以下劃線開頭的類別、欄位或方法僅在其所屬的檔案內可見和可用。
  6. 如何在不需要建構子主體的情況下初始化類別欄位? 你可以使用 Dart 的語法糖。例如,Point(this.x, this.y); 這樣的建構子可以直接將參數賦值給同名的實例欄位。
  7. 舉出一些使用泛型的例子,以及這樣做如何增強你的程式碼的類型安全。 泛型在集合(如 List、Map)和其他類別中非常有用。例如,使用 List<String> 可以確保列表中的所有元素都是字符串。這樣做可以提高程式碼的類型安全性,減少運行時的錯誤。
  8. 如果你呼叫一個非同步方法,使用或不使用 await 會對你的程式碼執行有什麼影響? 如果呼叫一個非同步方法並使用 await,則你的程式碼將在該方法完成之前暫停執行。這允許你等待非同步操作的結果。如果不使用 await,你的程式碼將繼續執行,不會等待非同步方法完成。這可能導致你的程式碼在得到非同步方法的結果之前就繼續向下執行。

上一篇
Day 7 Dart 物件導向 (下)
下一篇
Day 9 Widget 與使用者介面
系列文
Flutter 開發實戰 - 30 天逃離新手村27
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言