目錄

空安全:常見問題

此處涵蓋了一些我們在遷移 Google 內部程式碼至 空安全 時遇到的常見問題。

在遷移程式碼時,我應該注意哪些執行時的改動?

在空安全遷移中的大部分影響,不會立刻出現在剛剛遷移完成的開發者身上:

  • 靜態的空安全檢查,會在開發者完成遷移後立刻生效。

  • 完整的空安全檢查,只會在所有程式碼都已遷移,並且啟用了完整的空安全模式時生效。

但是有兩項例外你需要注意:

  • 對於任何模式而言,! 運運算元都是在執行時進行的空檢查。所以在進行遷移時,請確保你僅對 null 可能由混合模式造成汙染的程式碼位置新增 !,就算發起呼叫的程式碼還未遷移至空安全,也應如此。

  • late 會在執行時期檢查。所以請你僅在確定它被使用前一定會被初始化的情況下使用 late

如果在測試中的值始終為 null 應該怎樣處理?

如果在測試中某個值始終為 null,可以透過將測試傳值和測試需要的值改為非空,來改進你的測試。

新的 required@required 關鍵詞有何異同?

@required 註解會將引數標記為必須傳遞。如果未傳,分析器會給出一個提示。

有了空安全,非空型別的命名引數要麼需要預設值,要麼需要使用 required 關鍵字修飾。否則,它在未傳遞時預設會是 null,就顯得不合理了。

在舊的程式碼中,required 關鍵詞會被看作 @required 註解,即引數未傳遞時會顯示一個分析器提示。

當你在空安全的程式碼中使用空安全程式碼時,如果 required 修飾的引數未傳遞,會顯示一個錯誤。

那麼它對於遷移來說意味著什麼呢?當你給以前沒有使用 @required 註解的引數加上 required 時要十分小心。任何沒有傳遞新需要的引數的程式碼,都無法進行編譯。實際上,你可以加上預設值,或是將引數變為可空型別。

我應該如何遷移應該為 final 而目前並不是的欄位?

一些賦值計算可以移動到靜態的初始化中。與其使用下面的方式:

// Initialized without values
ListQueue _context;
Float32List _buffer;
dynamic _readObject;

Vec2D(Map<String, dynamic> object) {
  _buffer = Float32List.fromList([0.0, 0.0]);
  _readObject = object['container'];
  _context = ListQueue<dynamic>();
}

你可以這樣做:

// Initialized with values
final ListQueue _context = ListQueue<dynamic>();
final Float32List _buffer = Float32List.fromList([0.0, 0.0]);
final dynamic _readObject;

Vec2D(Map<String, dynamic> object) : _readObject = object['container'];

然而,在建構函式中透過計算進行初始化的欄位,是無法為 final 的。在使用空安全的時候,你會發現想讓它們的型別為非空,也不是一件容易的事。如果初始化的時機不合適,那麼直到初始化前,它都只能是可空的型別。幸運的是,你還有其他選擇:

  • 將構造轉變為工廠方法,並將其委託給一個直接初始化所有欄位的真正的建構函式。在 Dart 中,這樣的私有構造通常是一個下劃線:_。如此一來,欄位就可以是 final 且非空了。在空安全遷移介入 之前,你就可以這樣進行調整。

  • 或者,將欄位標記為 late final。這會使得欄位只被初始化一次。在它被讀取之前必須被初始化。

我應該如何遷移 built_value 類?

使用了 @nullable 註解的 getter 應當直接轉變為可空的型別,然後移除所有的 @nullable 註解。例如:

@nullable
int get count;

變成

int? get count; //  Variable initialized with ?

就算遷移工具建議,沒有 使用 @nullable 註解的 getter 也不應該是可空的型別。這時可以根據需要新增 ! 運運算元,並且重新進行分析。

我應該如何遷移可能返回 null 的工廠方法?

優先使用不返回 null 的工廠方法。我們看到了很多程式碼,本意是想在呼叫不正確時丟擲一個例外,但最終卻返回了空。

與其這樣寫:

factory StreamReader(dynamic data) {
  StreamReader reader;
  if (data is ByteData) {
    reader = BlockReader(data);
  } else if (data is Map) {
    reader = JSONBlockReader(data);
  }
  return reader;
}

不如這樣:

factory StreamReader(dynamic data) {
  if (data is ByteData) {
    // Move the readIndex forward for the binary reader.
    return BlockReader(data);
  } else if (data is Map) {
    return JSONBlockReader(data);
  } else {
    throw ArgumentError('Unexpected type for data');
  }
}

如果一個工廠方法的初衷就是可能返回空值,將其轉為可返回 null 的靜態方法是更好的選擇。

我應該如何遷移現在提示無用的 assert(x != null)

對於完全遷移的程式碼而言,這個斷言是不必要的,但是如果你希望保留該檢查,那麼它 也需要 留下。幾種方式可供你選擇:

  • 確定是否真的需要這個斷言,然後將其刪除。當斷言啟用時,這是一種行為上的變更。

  • 確定斷言始終會被檢查,接著將其轉換為 ArgumentError.checkNotNull。當斷言未啟用時,這是一種行為上的變更。

  • 透過新增 //ignore: unnecessary_null_comparison 來繞過警告並且保持原有的行為。

我應該如何遷移現在提示不必要的執行時空判斷?

如果 arg 為非空時,編譯器會在執行時將顯式空安全判斷標記為非必要。

if (arg == null) throw ArgumentError(...)`

混合模式下的程式必須包含這樣的判斷。在所有程式碼都遷移且執行在完全的空安全模式下前, arg 仍然可能為 null

保留這項行為的最簡單的方法是將判斷改為 ArgumentError.checkNotNull

執行時的檢查同樣適用。如果 arg 指定了靜態型別為 String,那麼 if (arg is! String) 實際上是在檢查 arg 是否為 null。儘管程式碼在遷移到空安全後,arg 應該是永遠不為 null 的,但是在不完全的空安全中它仍有可能為 null 的。

Iterable.firstWhere 方法不再接受 orElse: () => null

匯入 package:collection package 並使用 firstWhereOrNull 擴充方法代替 firstWhere

我應該如何處理有 setters 的屬性?

與上文說到的 late final 的建議不同的是,這些欄位不能被標記為終值。通常,可被修改的屬性也沒有初始值,因為它們可能會在稍後才被賦值。

在這樣的情況下,你有兩種選擇:

  • 為其設定初始值。通常情況下,初始值未被設定是無意的錯誤,而不是有意為之。

  • 如果你 確定 這個屬性需要在存取之前被賦值,將它標記為 late

    注意late 關鍵詞會在執行時新增檢查。如果在 set 之前呼叫了 get,會在執行時丟擲例外。

我需要怎樣標記對映的返回值為非空型別?

對映型別的 查詢運運算元 ([]) 返回的值預設是可空型別。此處沒有辦法告訴 Dart ,返回的值一定是非空的。

在這種情況下,你應該使用強制非空運運算元 (!) 將可空的型別轉為非空 (V)。

return blockTypes[key]!;

如果 map 返回了 null,則會丟擲例外。如果你希望手動處理這些情況:

var result = blockTypes[key];
if (result != null) return result;
// Handle the null case here, e.g. throw with explanation.

為什麼我的 List/Map 中的泛型是可空的?

下面這樣以可空內容結尾的程式碼是一種典型的程式碼異味:

List<Foo?> fooList; // fooList can contain null values

它隱含了 fooList 可能包含空值的資訊。在你以長度初始化列表並迴圈填入值時,這種情況可能會出現。

如果你僅僅想要以相同的值初始化列表,你應該使用 filled 構造。

_jellyCounts = List<int?>(jellyMax + 1);
for (var i = 0; i <= jellyMax; i++) {
  _jellyCounts[i] = 0; // List initialized with the same value
}
_jellyCounts = List<int>.filled(jellyMax + 1, 0); // List initialized with filled constructor

如果你需要透過索引來設定元素,或者使用不同的值填充每個元素,則應該使用列表的字面量表達式來建構列表。

_jellyPoints = List<Vec2D?>(jellyMax + 1);
for (var i = 0; i <= jellyMax; i++) {
  _jellyPoints[i] = Vec2D(); // Each list element is a distinct Vec2D
}
_jellyPoints = [
  for (var i = 0; i <= jellyMax; i++)
    Vec2D() // Each list element is a distinct Vec2D
];

你可以使用 List.generate 構造加 growable 引數設定為 false 來產生固定長度的列表:

_jellyPoints = List.generate(jellyMax, (_) => Vec2D(), growable: false);

預設的 List 構造有什麼改動?

你可能會遇到這樣的錯誤:

The default 'List' constructor isn't available when null safety is enabled. #default_list_constructor

預設的列表構造會將列表用 null 填充,會造成問題。

將它變為 List.filled(length, default) 即可。

我在遷移使用了 package:ffi 的程式碼的時候遇到了 Dart_CObject_kUnsupported 的錯誤。發生了什麼?

透過 ffi 傳送的列表只能是 List<dynamic>,而不能是 List<Object>List<Object?>。就算你未在遷移過程中手動更改型別,型別也可能會被改變,因為啟用空安全後,型別推導推算出了這樣的結果。

手動建立 List<dynamic> 型別的列表可以解決這個問題。

為什麼遷移工具在我的程式碼中添加了註釋

空安全模式下,在當某個表示式的結果一定為 false 或 true 的時候,遷移工具會自動新增 /* == false */ 或者 /* == true */ 這樣的註釋。這樣的註釋將會導致自動遷移出現錯誤,並且需要人工干預。例如:

if (registry.viewFactory(viewDescriptor.id) == null /* == false */)

在這些情況下,遷移工具無法區分防禦性編碼情況或是確實需要空值的情況。那麼該工具會告訴你「這看起來永遠為 false!」並讓你進行決定。

關於編譯為 JavaScript 時的空安全我應該知道什麼?

空安全帶來了程式碼體積減小及效能提升等最佳化。表面上 Flutter 編譯為原生端的建構的最佳化會更加明顯,例如 AOT。我們先前已經在 Web 的生產建構器上已經引入了一些類似空安全的最佳化。所以,Web 應用上的變化可能並不如原生端明顯。

依然有幾點值得注意:

  • 生產環境下的 JavaScript 編譯器會產生 ! 空斷言,在比較輸出的時候你可能不會注意到它。這是因為編譯器已支援了對空值進行檢查。

  • 無論是健全或非健全的安全,又或是不同的最佳化等級,編譯器都會產生這些空斷言,實際上在使用 -O3--omit-implicit-checks 時編譯器都不會移除 !

  • 生產環境下的 JavaScript 編譯器可能會移除沒有必要的空檢查,一般會發生在生產環境下的 Web 編譯器在空安全之前做了一些最佳化,當它知道值不為空的時候,就刪除了這些檢查。

  • 預設情況下,編譯器會產生引數子類別型的檢查。(用於確保協變的虛擬呼叫使用了合適的引數的執行時期檢查)。與先前相同,使用 --omit-implicit-checks 編譯器會省略它們。回想一下,如果型別無效,這個開關會讓程式出現例外,因此我們依然建議程式碼測試覆蓋率儘可能地高,以避免任何事故。特別是編譯器會基於傳入值符合型別宣告這一條件來最佳化程式碼。如果程式碼提供了無效型別的引數,最佳化將不是正確的,導致程式例外。這對於之前的型別不一致是正確的,對於現在健全的空安全的可空性不一致也是如此。

  • 你可能會注意到開發版的 JavaScript 編譯器和 Dart VM 對於空檢查的錯誤有比較特殊的錯誤提示,但是為了保持應用的輕量體積,生產環境下的編譯器並沒有這樣的提示。

  • 你可能會看到 .toStringnull 上未找到的錯誤。這不是一個 bug,是編譯器一直以來新增的空檢查。編譯器會透過對接收者物件屬性的存取來壓縮一些空檢查。它產生的是 a.toString 而不是 if (a == null) throw。在 JavaScript 物件中定義的 toString 方法可以快速驗證物件是否可空。

    如果空檢查後的第一個行為是當值為空時會崩潰,編譯器可以刪除空檢查並讓動作丟擲錯誤。

    例如,print(a!.foo()); 陳述式可以直接轉換為:

      P.print(a.foo$0());
    

    這是因為呼叫 a.foo$() 會在 a 為空時崩潰。如果 dart2js 內聯 foo,它將保留空檢查。例如,如果 fooint foo() => 1;,編譯器可能會產生:

      a.toString;
      P.print(1);
    

    如果內聯方法做的第一件事是對接收者的欄位存取,例如 int foo() => this.x + 1;,那麼 dart2js 可以再次刪除多餘的 a.toString 空檢查,就像非內聯呼叫一樣產生:

      P.print(a.x + 1);
    

資源