flutter [设计关注] EditableText的拼写检查过于冗长(资源消耗大)

e4eetjau  于 2个月前  发布在  Flutter
关注(0)|答案(3)|浏览(33)

重现步骤

为文本字段启用拼写检查,并快速输入。
底层平台的SpellcheckService将被调用很多次,因为许多结果会很快被丢弃。
在为Windows版本的应用程序补充当前未实现的拼写检查器时,我发现虽然'fetchSpellCheckSuggestions'确实是异步的,但它预计会很快返回结果。
如果不能这样做,将导致大量调用排队等待完成,停止输入后,您正在等待它们全部完成,影响用户体验。
尝试按键连击或在启用拼写检查的文本字段上按住退格键。
为了减轻这个问题,我在"Win32SpellCheckService"实现中实现了防抖动。我发现这会导致小部件中的渲染失败,如果用户按住退格键-因为当成功防抖动的调用执行时,文本字段中的字符串值现在可能比拼写检查时的字符串值短,这意味着SuggestionSpans可能引用超出范围的索引(这是EditableText小部件报告的错误)。
我建议将防抖动添加到EditableText的内置实现中,也许可以设置一个配置周期。似乎我能找到的所有内联拼写检查实现(Chrome、MS Word等)都使用防抖动来避免检查 transient 文本,这些文本很快就会被渲染脏。

预期结果

当快速输入文本字段时,拼写检查器的运行应该被防抖动。

实际结果

当快速输入文本字段时,拼写检查器的运行没有被防抖动,导致不必要的系统负载,影响用户体验。

代码示例

实际上这只是一个带有防抖动的win32拼写检查器的例子。在没有防抖动的情况下,它工作得很好(尽管性能不佳)。当然,它只能提供下划线,尽管它返回建议,但EditableText在Windows上并不使用它们。

import 'dart:async';
import 'dart:isolate';
import 'dart:math';
import 'dart:ui';
import 'package:flutter/services.dart';
import 'dart:ffi';
import 'package:ffi/ffi.dart';
import 'package:win32/win32.dart';

class Win32SpellCheckService implements SpellCheckService {
  final Map<String, _SpellCheckIsolate> _isolateCache = {};

  @override
  Future<List<SuggestionSpan>?> fetchSpellCheckSuggestions(Locale locale, String text) {
    final completer = Completer<List<SuggestionSpan>?>();
    final languageTag = locale.toLanguageTag();

    EasyDebounce2.debounce(
      'win32-spellcheck-debouncer',
      const Duration(milliseconds: 500),
      () {
        _processSpellCheck(languageTag, text)
          .then(completer.complete)
          .catchError(completer.completeError);
      },
      () => completer.complete([])
    );

    return completer.future;
  }

  Future<List<SuggestionSpan>?> _processSpellCheck(String languageTag, String text) async {
    if (!_isolateCache.containsKey(languageTag)) {
      await _initializeIsolate(languageTag);
    }
    
    return await _isolateCache[languageTag]!.check(text);
  }

  Future<void> _initializeIsolate(String languageTag) async {
    final receivePort = ReceivePort();
    final isolate = _SpellCheckIsolate();
    _isolateCache[languageTag] = isolate;
    
    receivePort.listen(isolate.handleResponsesFromIsolate);
    
    await Isolate.spawn(_startRemoteIsolate, receivePort.sendPort);
    final success = await isolate.initialize(languageTag);
    
    if (!success) {
      print('Spellcheck initialization failed, locale not supported: $languageTag');
    }
  }

  static void _startRemoteIsolate(SendPort port) {
    final receivePort = ReceivePort();
    port.send(receivePort.sendPort);

    Win32SpellCheckServiceWorker worker = Win32SpellCheckServiceWorker();

    receivePort.listen((dynamic message) async {
      if (message is SpellCheckIsolateMessage) {
        switch(message){
          case InitializeSpellCheckIsolateRequest(): 
            var initializeResponse = await worker.initialize(message.languageTag);
            port.send(InitializeSpellCheckIsolateResponse(initializeResponse));         
      
          case CheckSpellCheckIsolateRequest():
            var suggestions = await worker.check(message.text);
            port.send(CheckSpellCheckIsolateResponse(message.correlationId, suggestions));
        }
      }
    });
  }
}

class _SpellCheckIsolate
{
  SendPort? _sendPort;

  final Completer _isolateReadyCompleter = Completer();
  final Completer<bool> _initializeCompleter = Completer();
  final Map<String, Completer<Iterable<CheckError>>> _checkCompleters = {};
  late final String _locale;

  void handleResponsesFromIsolate(dynamic message) {
    if (message is SendPort) {
      _sendPort = message;
      _isolateReadyCompleter.complete();
    } else if (message is SpellCheckIsolateMessage) {
      switch (message){
        case InitializeSpellCheckIsolateResponse():
          if (_initializeCompleter.isCompleted != true)
          {
            _initializeCompleter.complete(message.success);
          }
        case CheckSpellCheckIsolateResponse():
          var completer = _checkCompleters[message.correlationId];
          if (completer?.isCompleted != true)
          {
            completer?.complete(message.errors);
          }
      }
    }    
  }

  Future<bool> initialize(String locale) async
  {
    _locale = locale;
    await _isolateReadyCompleter.future;    
    _sendPort!.send(InitializeSpellCheckIsolateRequest(locale));
    return await _initializeCompleter.future;
  }

  Future<List<SuggestionSpan>> check(String text) async 
  {
    await _isolateReadyCompleter.future;
    var results = <SuggestionSpan>[];
    var localeIsSupported = await _initializeCompleter.future;
    if (localeIsSupported)
    {
      Random random = Random(DateTime.now().microsecondsSinceEpoch);
      var correlation = random.nextInt(4294967295).toString();
      //var correlation = DateTime.now().microsecondsSinceEpoch.toString();
      var completer = Completer<Iterable<CheckError>>();
      _checkCompleters[correlation] = completer;
      _sendPort!.send(CheckSpellCheckIsolateRequest(correlation, text));
      var resultsMap = await completer.future;
      for(var result in resultsMap){
        var span = SuggestionSpan(
          TextRange(start: result.start, end: result.end, ),
          result.suggestions
        );
        results.add(span);
      }
    } else {
      print('Spellcheck failed, locale not supported: $_locale');
    }
    return results;
  }
}

class Win32SpellCheckServiceWorker {
  late final ISpellCheckerFactory _spellCheckerFactory;
  late final ISpellChecker _spellChecker;

  Win32SpellCheckServiceWorker() {
    CoInitializeEx(nullptr, COINIT.COINIT_MULTITHREADED);
    _spellCheckerFactory = SpellCheckerFactory.createInstance();
  }

  Future<bool> initialize(String locale) async
  {
    final languageTagPtr = locale.toNativeUtf16();
    final supportedPtr = calloc<Int32>();
    final spellCheckerPtr = calloc<COMObject>();
    
    try {
      _spellCheckerFactory.createSpellChecker(languageTagPtr, spellCheckerPtr.cast());
      _spellCheckerFactory.isSupported(languageTagPtr, supportedPtr);
      
      if (supportedPtr.value == 1) {
        _spellChecker = ISpellChecker(spellCheckerPtr);
        return true;
      } else {
        return false;  // Return empty result if language is not supported
      }
    } finally {
      free(languageTagPtr);
      free(supportedPtr);
      // We don't free spellCheckerPtr here as it's now owned by _spellCheckers
    }
  }

  Future<List<CheckError>> check(String text) async {
    var result = <CheckError>[];
    final errorsPtr = calloc<COMObject>();
    final textPtr = text.toNativeUtf16();
    try {
      final hr = _spellChecker.check(textPtr, errorsPtr.cast());
      if (!FAILED(hr)) {
        final errors = IEnumSpellingError(errorsPtr);
        await _processErrors(errors, _spellChecker, text, result);
        errors.detach();
      } else {
        print("Spellcheck native api call (ISpellChecker.check) failed with code: $hr");
      }
    } finally {
      free(textPtr);
      free(errorsPtr);
    }
    return result;
  }

  Future<void> _processErrors(IEnumSpellingError errors, ISpellChecker spellChecker, String text, List<CheckError> result) async {
    final errorPtr = calloc<COMObject>();
    int hr;
    while ((hr = errors.next(errorPtr.cast())) == S_OK) {
      final error = ISpellingError(errorPtr);
      await _processSingleError(error, spellChecker, text, result);
      error.detach();
    }
    if (FAILED(hr)){
      print("Spellcheck native api call (IEnumSpellingErrors.next) failed with code: $hr");
    }
    free(errorPtr);
  }

  Future<void> _processSingleError(ISpellingError error, ISpellChecker spellChecker, String text, List<CheckError> result) async {
    final word = text.substring(error.startIndex, error.startIndex + error.length);
   
    if (error.correctiveAction == CORRECTIVE_ACTION.CORRECTIVE_ACTION_GET_SUGGESTIONS) {
      final wordPtr = word.toNativeUtf16();
      final suggestionsPtr = calloc<COMObject>();
      try {
        final hr = spellChecker.suggest(wordPtr, suggestionsPtr.cast());
        if (!FAILED(hr)) {
          final suggestions = IEnumString(suggestionsPtr);
          final suggestionList = await _getSuggestions(suggestions);
          suggestions.detach();
          result.add(CheckError(
            error.startIndex, 
            error.startIndex + error.length,
            suggestionList,
          ));
        } else {
          print("Spellcheck native api call (ISpellChecker.getSuggestions) failed with code: $hr");
        }
      } finally {
        free(wordPtr);
        free(suggestionsPtr);
      }
    }
  }

  Future<List<String>> _getSuggestions(IEnumString suggestions) async {
    final suggestionPtr = calloc<Pointer<Utf16>>();
    final suggestionResultPtr = calloc<Uint32>();
    final suggestionList = <String>[];
    try {
      int hr;
      while ((hr = suggestions.next(1, suggestionPtr, suggestionResultPtr)) == S_OK) {
        suggestionList.add(suggestionPtr.value.toDartString());
      }
      if (FAILED(hr)){
        print("Spellcheck native api call (IEnumString.next) failed with code: $hr");
      }
    } finally {
      free(suggestionPtr);
      free(suggestionResultPtr);
    }
    return suggestionList;
  }

  void dispose() {
    _spellChecker.detach();
    _spellCheckerFactory.release();
    CoUninitialize();
  }
}

abstract class SpellCheckIsolateMessage
{
  final String correlationId;

  SpellCheckIsolateMessage(this.correlationId);
}

class InitializeSpellCheckIsolateRequest extends SpellCheckIsolateMessage
{
  final String languageTag;

  InitializeSpellCheckIsolateRequest(this.languageTag) : super('');
}

class InitializeSpellCheckIsolateResponse extends SpellCheckIsolateMessage
{
  final bool success;

  InitializeSpellCheckIsolateResponse(this.success) : super('');
}

class CheckSpellCheckIsolateRequest extends SpellCheckIsolateMessage
{
  final String text;

  CheckSpellCheckIsolateRequest(super.correlationId, this.text);
}

class CheckSpellCheckIsolateResponse extends SpellCheckIsolateMessage
{
  final List<CheckError> errors;

  CheckSpellCheckIsolateResponse(super.correlationId, this.errors);
}

class CheckError
{
  final int start;
  final int end;
  final List<String> suggestions;

  CheckError(this.start, this.end, this.suggestions);
}

/* a customised EasyDebounce which has a callback for victim invocations */

/// A void callback, i.e. (){}, so we don't need to import e.g. `dart.ui`
/// just for the VoidCallback type definition.
typedef EasyDebounceCallback = void Function();

class _EasyDebounceOperation {
  EasyDebounceCallback callback;
  EasyDebounceCallback cancelled;
  Timer timer;
  _EasyDebounceOperation(this.callback, this.cancelled, this.timer);
}

/// A static class for handling method call debouncing.
class EasyDebounce2 {
  static Map<String, _EasyDebounceOperation> _operations = {};

  /// Will delay the execution of [onExecute] with the given [duration]. If another call to
/// debounce() with the same [tag] happens within this duration, the first call will be
/// cancelled and the debouncer will start waiting for another [duration] before executing
/// [onExecute].
///
/// [tag] is any arbitrary String, and is used to identify this particular debounce
/// operation in subsequent calls to [debounce()] or [cancel()].
///
/// If [duration] is `Duration.zero`, [onExecute] will be executed immediately, i.e.
/// synchronously.
  static void debounce(
      String tag, Duration duration, EasyDebounceCallback onExecute, EasyDebounceCallback onCancelled) {
    if (duration == Duration.zero) {
      _operations[tag]?.timer.cancel();
      _operations.remove(tag);
      onExecute();
    } else {
      var operation = _operations[tag];
      if (null != operation){
        operation.timer.cancel();
        operation.cancelled();
      }      

      _operations[tag] = _EasyDebounceOperation(
          onExecute,
          onCancelled,
          Timer(duration, () {
            _operations[tag]?.timer.cancel();
            _operations.remove(tag);

            onExecute();
          }));
    }
  }

  /// Fires the callback associated with [tag] immediately. This does not cancel the debounce timer,
/// so if you want to invoke the callback and cancel the debounce timer, you must first call
/// `fire(tag)` and then `cancel(tag)`.
  static void fire(String tag) {
    _operations[tag]?.callback();
  }

  /// Cancels any active debounce operation with the given [tag].
  static void cancel(String tag) {
    _operations[tag]?.timer.cancel();
    _operations.remove(tag);
  }

  /// Cancels all active debouncers.
  static void cancelAll() {
    for (final operation in _operations.values) {
      operation.timer.cancel();
    }
    _operations.clear();
  }

  /// Returns the number of active debouncers (debouncers that haven't yet called their
/// [onExecute] methods).
  static int count() {
    return _operations.length;
  }
}

截图或视频

  • 无响应*

日志

  • 无响应*

Flutter Doctor输出

[√] Flutter (频道稳定,3.22.2,在Microsoft Windows [Version 10.0.19045.4529],区域设置 en-NZ)
[√] Windows版本(安装的Windows版本是10或更高版本)
[X] Android工具链 - 为Android设备开发
X 无法定位Android SDK。
从: https://developer.android.com/studio/index.html 安装Android Studio组件以帮助您首次启动。
(或者访问https://flutter.dev/docs/get-started/install/windows#android-setup以获取详细说明)。
如果已将Android SDK安装到自定义位置,请使用 flutter config --android-sdk 更新到该位置。
[√] Chrome - 为Web开发
[√] Visual Studio - 为Windows应用程序开发(Visual Studio Community 2022 17.10.4)
[!] Android Studio(未安装)
[√] VS Code(版本1.91.1)
[√] 已连接设备(3个可用)
[√] 网络资源

vyswwuz2

vyswwuz21#

感谢详细的报告@Adam-Langley
你是否在桌面上看到了报告中提到的行为?还是在其他平台上也有这种情况?
我建议在EditableText的内置实现中添加防抖功能。
似乎已经有社区包提供这个功能了,例如https://pub.dev/packages/flutter_debouncerhttps://pub.dev/packages/easy_debounce,你可以检查一下这是否有助于解决你的问题。

9gm1akwq

9gm1akwq2#

你好,感谢你的参与。
我目前只在为Windows定制/实现拼写检查功能,因此只在Windows上观察到了性能下降的情况。
然而,在深入研究了editabletext和spellcheck代码文件后,我发现完全没有使用防抖动(debouncing)。因此,我的报告针对的是更一般的情况——iOS和Android也很可能执行不必要的拼写检查。这是我的主要关注点。
你会看到我的代码示例已经使用了easydebounce来解决原生防抖动的缺失——然而,iOS和Android应用的开发人员不应该不得不重写并“修补”内置的默认拼写检查服务以避免这种次优行为。
当然,我可以花更多的时间提供移动端的性能指标,但我觉得熟悉这个的人应该能立刻知道我的代码解读是否正确。
如果我的报告没有按照我刚刚描述的目的正确提交,我应该如何修改它?
另外:我已经修改了我的原始报告和代码示例。在我最初的报告中,我提到拼写检查子系统是如何生成索引越界的错误的。这确实发生了,但我找到了这个原因,它可能是另一个错误——所以我会单独报告( #152272 )。我已经删除了那个评论,并修复了代码示例以避免这个错误。
感谢你的反馈

ma8fv8wu

ma8fv8wu3#

我目前仅针对Windows系统定制/实现拼写检查功能,因此在Windows上仅“观察到”了性能下降。
Flutter目前尚未支持桌面上的EditableText拼写检查功能。参见 #122433
然而,在深入研究editabletext和spellcheck代码文件后,我发现完全缺乏防抖动功能。因此,我的报告主要针对更一般的情况 - 即iOS和Android也可能执行不必要的拼写检查。这是我的主要关注点。
请查看使用 https://api.flutter.dev/flutter/material/TextField/enableSuggestions.html 并将其设置为 false 是否仍然具有相同的行为。
但我发现了这个原因,这可能是另一个错误 - 所以我将单独报告它( #152272 )。
如果这仅限于桌面,那么很可能是由于我之前分享的关联问题。

相关问题