原文地址:alphamikle.dev/why-should-…
釋出時間:2021年3月16日
在Flutter中管理狀態的方法有很多,但大多數都是以這樣的方式構建的,所有的邏輯都是在應用程式的主隔離區中執行的。網路請求的執行,與WebSocket的合作,潛在的重同步操作(如本地搜尋),所有這些,作為一個規則,在主隔離區中實現。本文將展示其他門以及?。
我只看到了一個旨在將這些操作轉移到外部隔離區的包,但最近又出現了一個包(由我編寫)。我建議你熟悉一下它。
在這篇文章中,我將使用兩個主要術語--隔離體和主執行緒。它們的區別是為了讓文字不至於太過同義,但本質上主線也是一種隔離物。另外,在這裡你會發現一些表達方式會割傷耳朵(或眼睛)特別敏感的天性,所以我提前道歉--對不起。我會把所有有疑問的詞用斜體標出(不光是他們,現在就試著弄明白)。另外,進一步呼叫操作同步--我會記住,你會在呼叫第三方方法的同一個函式中收到結果。而非同步函式是指你不會在原地得到結果,而是在另一個函式中得到。
介紹
隔離器的設計是為了在您的Flutter應用程式的非主執行緒上執行程式碼。當主執行緒開始執行網路請求,執行計算,或做任何其他操作,除了它的主要目的--繪製介面,你遲早會面臨這樣一個事實,即渲染一幀的寶貴時間將開始增加。基本上,你在主執行緒上執行任何操作的時間被限制在16ms,這就是在60FPS下渲染2幀之間存在的視窗。不過,目前有很多手機的顯示頻率較高,由於我只有一臺,所以用不同的方法來比較同樣操作的應用的效能,就越有意思。本例中,視窗已經是11.11ms,顯示重新整理率為90FPS。
實驗一:初始條件
讓我們想象一下,你需要載入大量的資料,你可以通過幾種方式來實現。
- 只要在主執行緒上提出一個請求
- 使用計算函式進行請求
- 明確使用隔離請求
實驗是在一臺搭載驍龍855處理器、螢幕頻率強制到90Hz的OnePlus 7 Pro上進行的。通過flutter run profile命令啟動應用程式。進行了從伺服器接收資料的模擬(連續10次同時請求5次)。
一次請求返回JSON--一個2273個元素的陣列,其中一個元素如截圖所示。答案的大小是1.12Mb。因此,對於5個同步請求,我們需要解析5.6Mb的JSON(但應用列表中會有2273個專案)。
我們從這樣的引數--幀渲染時間、操作時間、組織/編寫程式碼的複雜程度來比較三種方法。
第一個例子。一組來自主執行緒的請求
Future<void> loadItemsOnMainThread() async {
_startFpsMeter();
isLoading = true;
notifyListeners();
List<Item> mainThreadItems;
for (int i = 0; i < 10; i++) {
bench.startTimer('Load items in main thread');
mainThreadItems = await makeManyRequests(5);
final double diff = bench.endTimer('Load items in main thread');
requestDurations.add(diff);
}
items.clear();
items.addAll(mainThreadItems);
isLoading = false;
notifyListeners();
_stopFpsMeter();
requestDurations.clear();
}
複製程式碼
該方法駐留在應用程式主隔離區執行的反應狀態中。
該方法位於反應狀態下,在應用程式的主隔離區中執行。當執行上面的程式碼時,我們得到以下值。
- 一幀的平均渲染時間(FrameRenderingTime)- 14.036ms / 71.25FPS。
- 最大 FRT- 100.332ms / 9.97FPS
- 執行5個併發請求的平均時間 - 226.894ms
請看實際操作。
第二個例子:compute()
Future<void> loadItemsWithComputed() async {
_startFpsMeter();
isLoading = true;
notifyListeners();
List<Item> computedItems;
/// There were two variants of execution
/// Each set of 5 concurrent requests, run sequentially,
/// ran in compute function
if (true) {
for (int i = 0; i < 10; i++) {
bench.startTimer('Load items in computed');
computedItems = await compute<dynamic, List<Item>>(_loadItemsWithComputed, null);
final double diff = bench.endTimer('Load items in computed');
requestDurations.add(diff);
}
/// The second option is all 10 requests of 5 in one compute function
} else {
bench.startTimer('Load items in computed');
computedItems = await compute<dynamic, List<Item>>(_loadAllItemsWithComputed, null);
final double diff = bench.endTimer('Load items in computed');
requestDurations.add(diff);
}
items.clear();
items.addAll(computedItems);
isLoading = false;
notifyListeners();
_stopFpsMeter();
requestDurations.clear();
}
Future<List<Item>> _loadItemsWithComputed([dynamic _]) async {
return makeManyRequests(5);
}
Future<List<Item>> _loadAllItemsWithComputed([dynamic _]) async {
List<Item> items;
for (int i = 0; i < 10; i++) {
items = await makeManyRequests(5);
}
return items;
}
複製程式碼
在這個例子中,同樣的請求在兩個版本中發起:在10個連續請求中,每5個併發請求都在自己的計算中發起。
- 平均FRT - 11.254ms / 88.86FPS。
- 最大 FRT - 22.304ms / 44.84FPS
- 執行5個併發請求的平均時間 - 386.253ms
第二種方式--5個同時請求的10個順序請求都在一次計算中發起。
- 平均FRT - 11.252ms / 88.87FPS。
- 最大 FRT - 22.306ms / 44.83FPS
- 5個併發請求的平均時間(計算中執行5個請求中的全部10個,除以10)-231.747ms。
第三個例子。隔離
這裡值得做一個題外話:在包的術語中,一般狀態(state)有兩個部分。
- 前端狀態是一個任何反應式的狀態,它向後端傳送訊息,處理它的響應,也儲存資料,更新後UI會被更新,它也儲存從UI呼叫的輕量方法。這個狀態工作在應用程式的主執行緒中。
- Backend-state是一個重狀態,它接收來自Frontend的訊息,執行重操作,向Frontend返回響應,並在一個單獨的隔離區工作。這個狀態也可以儲存資料(無論你在哪裡)。
由於需要與隔離區進行通訊,第三種方案的程式碼被拆分成幾個方法。前端方法如下圖所示。
/// This method is the entry point to the operation
Future<void> loadItemsWithIsolate() async {
/// We start the frame counter before the whole operation
_startFpsMeter();
isLoading = true;
notifyListeners();
/// We start counting the request time
bench.startTimer('Load items in separate isolate');
/// Sending an event to Backend
send(Events.startLoadingItems);
}
/// The [Events.loadingItems] event handler for updating the request time from the isolate
void _middleLoadingEvent() {
final double time = bench.endTimer('Load items in separate isolate');
requestDurations.add(time);
bench.startTimer('Load items in separate isolate');
}
/// The [Events.endLoadingItems] terminating event handler from the isolate
Future<void> _endLoadingEvents(List<Item> items) async {
this.items.clear();
/// Updating data in reactive state
this.items.addAll(items);
/// Finishing counting request times
final double time = bench.endTimer('Load items in separate isolate');
requestDurations.add(time);
isLoading = false;
notifyListeners();
/// Stop the frame counter
_stopFpsMeter();
requestDurations.clear();
}
複製程式碼
在這裡你可以看到後端方法,以及我們需要的邏輯。
/// Event handler [Events.startLoadingItems]
Future<void> _loadingItems() async {
_items.clear();
for (int i = 0; i < 10; i++) {
_items.addAll(await makeManyRequests(5));
if (i < (10 - 1)) {
/// For all requests except the last one - we send only one event
send(Events.loadingItems);
} else {
/// For the last of 10 requests - send a message with data
send(Events.endLoadingItems, _items);
}
}
}
複製程式碼
結果。
- 平均FRT - 11.151ms / 89.68FPS
- 最大 FRT - 11.152ms / 89.67FPS
小計
在應用中進行三次載入同一資料集的實驗後,我們得到以下結果。
從這些結果來看,可以得出以下結論。
- Flutter能夠穩定地提供90FPS的畫面
- 在您的應用程式的主執行緒上進行大量的網路請求會影響其效能--出現延遲。
- 編寫在主執行緒上執行的程式碼,就像剝梨子一樣容易。
- 計算允許你減少滯後的可見性。
- 使用Compute寫程式碼有一定的侷限性(純函式,靜態方法不能傳遞,不能用閉包等)。
- 按操作時間計算,使用compute時的開銷~150-160ms。
- 隔離完全消除滯後
- 使用隔離體編寫程式碼是比較困難的,而且也有一些侷限性,這一點將在後面討論。
讓我們再進行一次實驗,以確定哪種方法對所研究的所有引數都是最優的。
實驗二:區域性搜尋
讓我們想象一下,現在我們需要通過輸入的值在載入的資料中找到某些元素。這個測試是這樣實現的:有一個輸入,從專案列表中可用的子串數中逐字輸入3個字元的子串。用於搜尋的陣列中的元素數量增加了10倍,為22730個。
搜尋以2種模式進行--從列表中的元素中輸入字串的原始存在,以及使用字串相似性演算法。
另外,非同步搜尋選項--compute / Isolate,在前一個搜尋完成之前不會啟動。它的工作原理是這樣的--在輸入欄位中輸入第一個字元,我們就開始搜尋,直到搜尋完成--資料不會返回主執行緒,UI也不會重新繪製,第二個字元不會在輸入欄位中輸入。當所有操作完成後,輸入第二個字元,也反之。這類似於我們 "儲存 "使用者輸入的字元時的演算法,然後只傳送一個請求,而不是為絕對每一個輸入的字元傳送一個請求,不管它們輸入得多快。
渲染時間只在字元輸入搜尋時進行測量,即資料準備操作,其他任何事情都不會影響收集到的資料。
對於初學者來說,幫助函式,搜尋函式和其他通用程式碼。
/// Function for creating a copy of elements
/// used as source for filtering
void cacheItems() {
_notFilteredItems.clear();
final List<Item> multipliedItems = [];
for (int i = 0; i < 10; i++) {
multipliedItems.addAll(items);
}
_notFilteredItems.addAll(multipliedItems);
}
複製程式碼
/// Function that launches a test script
/// for entering characters into a text input
Future<void> _testSearch() async {
List<String> words = items.map((Item item) => item.profile.replaceAll('https://opencollective.com/', '')).toSet().toList();
words = words
.map((String word) {
final String newWord = word.substring(0, min(word.length, 3));
return newWord;
})
.toSet()
.take(3)
.toList();
/// Start the frame counter
_startFpsMeter();
for (String word in words) {
final List<String> letters = word.split('');
String search = '';
for (String letter in letters) {
search += letter;
await _setWord(search);
}
while (search.isNotEmpty) {
search = search.substring(0, search.length - 1);
await _setWord(search);
}
}
/// Stop the frame counter
_stopFpsMeter();
}
複製程式碼
/// We enter characters with a delay of 800ms,
/// but if the data from the asynchronous filter (computed / isolate)
/// has not yet arrived, then we are waiting for them
Future<void> _setWord(String word) async {
if (!canPlaceNextLetter) {
await wait(800);
await _setWord(word);
} else {
searchController.value = TextEditingValue(text: word);
await wait(800);
}
}
複製程式碼
/// Depending on the set flag [USE_SIMILARITY]
/// whether or not search with string similarity is used
List<Item> filterItems(Packet2<List<Item>, String> itemsAndInputValue) {
return itemsAndInputValue.value.where((Item item) {
return item.profile.contains(itemsAndInputValue.value2) || (USE_SIMILARITY && isStringsSimilar(item.profile, itemsAndInputValue.value2));
}).toList();
}
bool isStringsSimilar(String first, String second) {
return max(StringSimilarity.compareTwoStrings(first, second), StringSimilarity.compareTwoStrings(second, first)) >= 0.3);
}
複製程式碼
在主線中搜尋
Future<void> runSearchOnMainThread() async {
cacheItems();
isLoading = true;
notifyListeners();
searchController.addListener(_searchOnMainThread);
await _testSearch();
searchController.removeListener(_searchOnMainThread);
isLoading = false;
notifyListeners();
}
void _searchOnMainThread() {
final String searchValue = searchController.text;
if (searchValue.isEmpty && items.length != _notFilteredItems.length) {
items.clear();
items.addAll(_notFilteredItems);
notifyListeners();
return;
}
items.clear();
/// Packet2 - wrapper class for two values
items.addAll(filterItems(Packet2(_notFilteredItems, searchValue)));
notifyListeners();
}
複製程式碼
這就是它的樣子。
簡單的搜尋結果。
- 平均FRT - 21.588ms / 46.32FPS。
- 最大 FRT - 668,986ms / 1.50FPS
用相似性結果搜尋。
- 平均FRT - 43,123ms / 23.19FPS。
- 最大 FRT - 2 440,910ms / 0.41FPS
用compute()搜尋
Future<void> runSearchWithCompute() async {
cacheItems();
isLoading = true;
notifyListeners();
searchController.addListener(_searchWithCompute);
await _testSearch();
searchController.removeListener(_searchWithCompute);
isLoading = false;
notifyListeners();
}
Future<void> _searchWithCompute() async {
canPlaceNextLetter = false;
/// Before starting filtering, set a flag that will signal
/// that asynchronous filtering is taking place
isSearching = true;
notifyListeners();
final String searchValue = searchController.text;
if (searchValue.isEmpty && items.length != _notFilteredItems.length) {
items.clear();
items.addAll(_notFilteredItems);
isSearching = false;
notifyListeners();
await wait(800);
canPlaceNextLetter = true;
return;
}
final List<Item> filteredItems = await compute(filterItems, Packet2(_notFilteredItems, searchValue));
/// After filtering is finished, remove the signal
isSearching = false;
notifyListeners();
await wait(800);
items.clear();
items.addAll(filteredItems);
notifyListeners();
canPlaceNextLetter = true;
}
複製程式碼
一些YouTube。
簡單的搜尋結果。
- 平均FRT - 12.682ms / 78.85FPS。
- 最大 FRT - 111.544ms / 8.97FPS
用相似性結果搜尋。
- 平均FRT - 12.515ms / 79.90FPS。
- 最大 FRT - 111,527ms / 8.97FPS
用Isolate搜尋
程式碼不多。前端
/// Start operation in isolate by sending message
Future<void> runSearchInIsolate() async {
send(Events.cacheItems);
}
void _middleLoadingEvent() {
final double time = bench.endTimer('Load items in separate isolate');
requestDurations.add(time);
bench.startTimer('Load items in separate isolate');
}
/// This method will called on event [Events.cacheItems], which will sent by Backend
Future<void> _startSearchOnIsolate() async {
isLoading = true;
notifyListeners();
searchController.addListener(_searchInIsolate);
await _testSearch();
searchController.removeListener(_searchInIsolate);
isLoading = false;
notifyListeners();
}
/// On every input event we send message to Backend
void _searchInIsolate() {
canPlaceNextLetter = false;
isSearching = true;
notifyListeners();
send(Events.startSearch, searchController.text);
}
/// Writing data from Backend (isolate) to Frontend (reactive state)
Future<void> _setFilteredItems(List<Item> filteredItems) async {
isSearching = false;
notifyListeners();
await wait(800);
items.clear();
items.addAll(filteredItems);
notifyListeners();
canPlaceNextLetter = true;
}
Future<void> _endLoadingEvents(List<Item> items) async {
this.items.clear();
this.items.addAll(items);
final double time = bench.endTimer('Load items in separate isolate');
requestDurations.add(time);
await wait(800);
isLoading = false;
notifyListeners();
_stopFpsMeter();
print('Load items in isolate ->' + requestDurations.join(' ').replaceAll('.', ','));
requestDurations.clear();
}
複製程式碼
而這些方法都是在第三方隔離區執行的後臺。
/// Handler for event [Events.cacheItems]
void _cacheItems() {
_notFilteredItems.clear();
final List<Item> multipliedItems = [];
for (int i = 0; i < 10; i++) {
multipliedItems.addAll(_items);
}
_notFilteredItems.addAll(multipliedItems);
send(Events.cacheItems);
}
/// For each event [Events.startSearch] this method is called,
/// filtering elements and sending the filtered to the light state
void _filterItems(String searchValue) {
if (searchValue.isEmpty) {
_items.clear();
_items.addAll(_notFilteredItems);
send(ThirdEvents.setFilteredItems, _items);
return;
}
final List<Item> filteredItems = filterItems(Packet2(_notFilteredItems, searchValue));
_items.clear();
_items.addAll(filteredItems);
send(Events.setFilteredItems, _items);
}
複製程式碼
簡單的搜尋結果。
- 平均FRT - 11.354ms / 88.08FPS
- 最大 FRT - 33.455ms / 29.89FPS
用相似性搜尋。
- 平均FRT - 11.353ms / 88.08FPS。
- 最大 FRT - 33.459ms / 29.89FPS
更多結論
從這個平板電腦和之前的研究來看。
- 主線不應該用於大於16ms的操作(至少提供60FPS)。
- Compute在技術上適用於頻繁和大量的操作,但會帶來同樣150ms的開銷,而且與永久隔離相比,效能也更不穩定(這可能是由於每次開啟,和操作完成後,隔離都會被關閉 ,這也需要資源)。
- 在Flutter應用中,隔離是實現最大效能的最難的程式碼方式。
隔離器
好吧,看來隔離是實現這個結果的理想方式,連Google都建議所有的重度操作都使用隔離(這是為了口口相傳,我沒有找到任何證明)。但是你必須要寫很多程式碼。事實上,上面所寫的一切都是使用最開始介紹的庫實現的結果,如果沒有它--你將不得不寫很多很多。另外,這個搜尋演算法還可以進行優化--在過濾完所有元素後,只傳送一小部分資料到前面--這樣會佔用更少的資源,在其傳輸後,再傳送其他所有的資料。或者按塊傳送資料。
我還對隔離體之間的通訊通道的頻寬進行了實驗。為了評估它,使用了以下實體。
class Item {
const Item(
this.id,
this.createdAt,
this.profile,
this.imageUrl,
);
final int id;
final DateTime createdAt;
final String profile;
final String imageUrl;
}
複製程式碼
而結果是--同時傳輸5000個元素,複製資料所需的時間並不影響UI,即渲染頻率並沒有降低。1,000,000個這樣的元素,通過Future.delayed分批傳輸,每次5000個,在8ms的突發傳輸之間強制暫停,而幀率並沒有下降到80FPS以下。遺憾的是,我在寫這篇文章之前很久就做了這個實驗,沒有乾貨資料(如果有要求,會出現)。
很多人可能覺得處理隔離器很困難或者沒有必要,大家就止步於計算。在這裡,這個包的另一個功能可以派上用場,它把API等同於計算的簡單性,結果,它給了更多的可能性。
下面是一個例子。
/// Frontend part
Future<void> decrement([int diff = 1]) async {
counter = await runBackendMethod<int, int>(Events.decrement, diff);
}
/// -----
/// Backend part
Future<int> _decrement(int diff) async {
counter -= diff;
return counter;
}
複製程式碼
由於這種方法,你可以簡單地通過這個函式對應的ID來呼叫後臺函式。ID與方法的匹配是在預定義的getter中指定的。
/// Frontend part
/// This block is responsible for handling events from the isolate.
@override
Map<Events, Function> get tasks => {
Events.increment: _setCounter,
Events.decrement: _setCounter,
Events.error: _setCounter,
};
/// -----
/// Backend part
/// And this one is for handling events from the main thread
@override
Map<Events, Function> get operations => {
Events.increment: _increment,
Events.decrement: _decrement,
};
複製程式碼
因此,我們得到兩種互動方式。
- 通過明確的訊息傳遞進行非同步通訊
前端使用send方法向後端傳送一個事件,在訊息中傳遞事件ID和一個可選的引數。
enum Events {
increment,
}
class FirstState with Frontend<Events> {
int counter = 0;
void increment([int diff = 1]) {
send(Events.increment, diff);
}
void _setCounter(int value) {
counter = value;
notifyListeners();
}
@override
Map<Events, Function> get tasks => {
Events.increment: _setCounter,
};
}
複製程式碼
該訊息被傳遞到後臺並在那裡進行處理。
class FirstBackend extends Backend<Events, void> {
FirstBackend(BackendArgument<void> argument) : super(argument);
int counter = 0;
void _increment(int diff) {
counter += diff;
send(Events.increment, counter);
}
@override
Map<Events, Function> get operations => {
Events.increment: _increment,
};
}
複製程式碼
後臺將結果返回給前臺就可以了! 返回結果有兩種方式--通過使用後臺方法(return)返回響應(那麼響應將以與接收到的訊息ID相同的訊息傳送),第二種是顯式呼叫傳送方法。在這種情況下,你可以用你指定的任何ID向反應狀態傳送任何訊息。最主要的是,處理方法是由這些ID設定的。
從原理上看,第一種方式是這樣的。
黃色雙面箭頭--與外界的任何服務進行互動,例如,某個伺服器。而紫色的,從伺服器到後端--這些是來自同一伺服器的傳入訊息,例如--WebSocket。
- 通過呼叫後端函式的ID來實現同步通訊
前端使用runBackendMethod方法,指定一個ID來呼叫對應的後端方法,直接得到響應。這樣一來,甚至不需要在你的front的任務列表中指定什麼。同時,如下面的程式碼所示,你可以在你的前端覆蓋onBackendResponse方法,每次你的前端狀態收到來自後端的訊息時,都會呼叫這個方法。
enum Events {
decrement,
}
class FirstState with ChangeNotifier, Frontend<Events> {
int counter = 0;
Future<void> decrement([int diff = 1]) async {
counter = await runBackendMethod<int, int>(Events.decrement, diff);
}
/// Automatically notification after any event from backend
@override
void onBackendResponse() {
notifyListeners();
}
}
複製程式碼
後面的方法對傳入的事件進行處理,並簡單地返回結果。在這種情況下,有一個限制--被稱為 "同步 "的後端方法不應該呼叫它們所對應的相同ID的傳送方法。在這個例子中,_decrement
方法不應該呼叫傳送(Events.decrement)方法。同時,他可以傳送任何其他訊息。
class FirstBackend extends Backend<Events, void> {
FirstBackend(BackendArgument<void> argument) : super(argument);
int counter = 0;
/// Or, you can simply return a value
Future<int> _decrement(int diff) async {
counter -= diff;
return counter;
}
@override
Map<Events, Function> get operations => {
Events.decrement: _decrement,
};
}
複製程式碼
第二種方式的方案與第一種類似,只是在前面你不需要寫來自後面的事件處理程式。
很快,在0.0.5版本中,這個功能就可以工作了,並且在後退中--你可以從它的後臺以同步模式執行Frontend的任務。
還有什麼要新增的...
要使用這樣的捆綁,你需要建立這些後端。為此,Frontend<EventType>
有一個後端建立機制--initBackend方法。在這個方法中,你需要傳遞一個工廠函式來建立後端。它應該是一個純頂層函式(Flutter文件中說的頂層),或者是一個靜態類方法。建立一個隔離體的時間大約是200ms。
enum Events {
increment,
decrement,
}
class FirstState with ChangeNotifier, Frontend<Events> {
int counter = 0;
void increment([int diff = 1]) {
send(Events.increment, diff);
}
Future<void> decrement([int diff = 1]) async {
counter = await runBackendMethod<int, int>(Events.decrement, diff);
}
void _setCounter(int value) {
counter = value;
}
Future<void> initState() async {
await initBackend(createFirstBackend);
}
/// Automatically notification after any event from backend
@override
void onBackendResponse() {
notifyListeners();
}
@override
Map<Events, Function> get tasks => {
Events.increment: _setCounter,
};
}
複製程式碼
後臺部件建立函式的一個例子。
typedef Creator<TDataType> = void Function(BackendArgument<TDataType> argument);
void createFirstBackend(BackendArgument<void> argument) {
FirstBackend(argument.toFrontend);
}
@protected
Future<void> initBackend<TDataType extends Object>(Creator<TDataType> creator, {TDataType data, ErrorHandler errorHandler}) async {
/// ...
}
複製程式碼
侷限性
- 一切都和普通隔離劑一樣
- 對於每一個正在建立的 "後端",當前都在建立自己的隔離區,如果後端太多,它們的建立時間就會變得很明顯,特別是當你初始化所有的後端時,比如說在載入應用程式的時候。我實驗過同時執行30個後端--上述手機在--釋放模式下的啟動時間需要6秒多。
- 在處理隔離體(後端)出現的錯誤時,存在一些困難。在這裡,如果你對這個包感興趣,你應該更詳細地熟悉Frontend的initBackend方法。
- 與只在主執行緒中儲存邏輯相比,編寫程式碼的複雜度較高
使用清單
這裡的一切都很簡單,你不需要使用隔離劑(無論是單獨使用還是使用這個包),如果。
- 你的應用程式在各種操作下的效能都不會下降。
- 對於瓶頸問題,計算就夠了。
- 你不會想處理隔離物吧?
- 你的應用程式的生命週期很短,所以沒有必要優化它。
否則,你可以將注意力轉向這種方法和一個包(稱為Isolator),它將簡化你對隔離物的工作。
本文的所有例子都可以在Github上找到。
通過www.DeepL.com/Translator(免費版)翻譯