原文地址: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(免费版)翻译