mirror of
https://github.com/wanghongenpin/proxypin.git
synced 2026-04-14 20:53:34 +08:00
optimize rendering of large texts
This commit is contained in:
266
lib/ui/component/search/virtualized_highlight_text.dart
Normal file
266
lib/ui/component/search/virtualized_highlight_text.dart
Normal file
@@ -0,0 +1,266 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:proxypin/network/util/logger.dart';
|
||||
import 'package:proxypin/ui/component/search/highlight_text_document.dart';
|
||||
import 'package:proxypin/ui/component/search/search_controller.dart';
|
||||
import 'package:proxypin/ui/component/utils.dart';
|
||||
import 'package:proxypin/utils/platform.dart';
|
||||
import 'package:scrollable_positioned_list_nic/scrollable_positioned_list_nic.dart';
|
||||
|
||||
class VirtualizedHighlightText extends StatefulWidget {
|
||||
final String text;
|
||||
final String? language;
|
||||
final TextStyle? style;
|
||||
final EditableTextContextMenuBuilder? contextMenuBuilder;
|
||||
final SearchTextController searchController;
|
||||
final ScrollController? scrollController;
|
||||
final double? height;
|
||||
final int chunkLines;
|
||||
|
||||
const VirtualizedHighlightText({
|
||||
super.key,
|
||||
required this.text,
|
||||
this.language,
|
||||
this.style,
|
||||
this.contextMenuBuilder,
|
||||
required this.searchController,
|
||||
this.scrollController,
|
||||
this.height,
|
||||
this.chunkLines = 80,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VirtualizedHighlightText> createState() => _VirtualizedHighlightTextState();
|
||||
}
|
||||
|
||||
class _VirtualizedHighlightTextState extends State<VirtualizedHighlightText> {
|
||||
final ItemScrollController itemScrollController = ItemScrollController();
|
||||
ScrollController? trackingScrollController;
|
||||
int _lastScrolledMatchIndex = -1;
|
||||
|
||||
// 缓存机制,避免重复计算
|
||||
HighlightTextDocument? _cachedDocument;
|
||||
String? _cachedText;
|
||||
SearchSettings? _cachedSearchSettings;
|
||||
late final Map<int, List<InlineSpan>> _chunkSpanCache;
|
||||
late List<HighlightDocumentChunk> chunks;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chunkSpanCache = {};
|
||||
// 初始化chunks为空列表,避免late未初始化错误
|
||||
chunks = [];
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
trackingScrollController?.dispose();
|
||||
_chunkSpanCache.clear();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final viewHeight = widget.height ?? max(240, MediaQuery.sizeOf(context).height - 220);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: widget.searchController,
|
||||
builder: (context, child) {
|
||||
// 只在文本或搜索参数(模式、大小写敏感性、正则表达式)变化时重新创建document
|
||||
// 不在currentMatchIndex变化时重新创建,以避免频繁rebuild
|
||||
final newSearchSettings = SearchSettings(
|
||||
isCaseSensitive: widget.searchController.value.isCaseSensitive,
|
||||
isRegExp: widget.searchController.value.isRegExp,
|
||||
pattern: widget.searchController.value.pattern,
|
||||
currentMatchIndex: 0, // 忽略currentMatchIndex用于比较
|
||||
);
|
||||
|
||||
final shouldRebuildDocument =
|
||||
_cachedText != widget.text ||
|
||||
_cachedSearchSettings != newSearchSettings;
|
||||
|
||||
if (shouldRebuildDocument) {
|
||||
_cachedDocument = HighlightTextDocument.create(
|
||||
context,
|
||||
text: widget.text,
|
||||
language: widget.language,
|
||||
style: widget.style,
|
||||
searchController: widget.searchController,
|
||||
);
|
||||
_cachedText = widget.text;
|
||||
_cachedSearchSettings = newSearchSettings;
|
||||
// 清除旧的块缓存
|
||||
_chunkSpanCache.clear();
|
||||
// 重新分块
|
||||
chunks = _buildChunks(_cachedDocument!, widget.chunkLines);
|
||||
}
|
||||
|
||||
_updateSearchState(_cachedDocument!);
|
||||
|
||||
return _buildList(viewHeight, chunks, (index) {
|
||||
final chunk = chunks[index];
|
||||
final chunkSpans = _chunkSpanCache.putIfAbsent(
|
||||
index,
|
||||
() => _buildSpansForChunk(context, _cachedDocument!, chunk),
|
||||
);
|
||||
return Text.rich(TextSpan(children: chunkSpans));
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildList<T>(double viewHeight, List<T> items, Widget Function(int) itemBuilder) {
|
||||
// 根据文本块大小动态调整缓存范围,避免过度缓存导致的内存和CPU消耗
|
||||
// 缓存范围应该是视口高度的2-3倍
|
||||
final estimatedItemHeight = 24.0; // 粗略估计单行高度(monospace)
|
||||
final itemsInView = max(3, (viewHeight / estimatedItemHeight).ceil());
|
||||
final minCacheExtent = estimatedItemHeight * itemsInView * 2;
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: viewHeight,
|
||||
child: SelectionArea(
|
||||
child: ScrollablePositionedList.builder(
|
||||
key: const ValueKey('virtualized-highlight-text'),
|
||||
physics: Platforms.isDesktop() ? null : const BouncingScrollPhysics(),
|
||||
scrollController: Platforms.isDesktop() ? null : _trackingScroll(),
|
||||
itemScrollController: itemScrollController,
|
||||
minCacheExtent: minCacheExtent,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Container(
|
||||
key: ValueKey('virtualized-code-chunk-$index'),
|
||||
child: itemBuilder(index),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _updateSearchState(HighlightTextDocument document) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
// 使用缓存的文档,确保使用最新的数据
|
||||
final cachedDoc = _cachedDocument;
|
||||
if (cachedDoc == null) return;
|
||||
|
||||
widget.searchController.updateMatchCount(cachedDoc.totalMatchCount);
|
||||
final currentMatch = widget.searchController.currentMatchIndex.value;
|
||||
if (currentMatch != _lastScrolledMatchIndex && currentMatch >= 0) {
|
||||
_lastScrolledMatchIndex = currentMatch;
|
||||
_scrollToCurrentMatch(cachedDoc);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _scrollToCurrentMatch(HighlightTextDocument document) async {
|
||||
// 防守性检查:确保所有条件都满足才进行滚动
|
||||
if (document.totalMatchCount == 0 || chunks.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 必须从 searchController 获取最新的匹配索引,
|
||||
// 因为 document.currentMatchIndex 是创建时的快照,缓存后不会更新
|
||||
final matchIndex = widget.searchController.currentMatchIndex.value;
|
||||
final lineIndex = document.lineIndexForMatch(matchIndex);
|
||||
|
||||
if (lineIndex == null || lineIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据行索引找到对应的块索引
|
||||
// 找到包含该行的块
|
||||
int chunkIndex = -1;
|
||||
for (var i = 0; i < chunks.length; i++) {
|
||||
final chunk = chunks[i];
|
||||
// 检查该行是否在这个块的范围内
|
||||
if (lineIndex >= chunk.startLineIndex && lineIndex < chunk.endLineIndex) {
|
||||
chunkIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没找到(lineIndex超出所有块范围,防守性编程),使用最后一个块
|
||||
if (chunkIndex == -1) {
|
||||
chunkIndex = max(0, chunks.length - 1);
|
||||
}
|
||||
|
||||
if (!itemScrollController.isAttached) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await itemScrollController.scrollTo(
|
||||
index: chunkIndex,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOut,
|
||||
alignment: 0.45,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.w('VirtualizedHighlightText scroll failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
ScrollController _trackingScroll() {
|
||||
if (trackingScrollController != null) {
|
||||
return trackingScrollController!;
|
||||
}
|
||||
|
||||
trackingScrollController = trackingScroll(widget.scrollController) ?? TrackingScrollController();
|
||||
return trackingScrollController!;
|
||||
}
|
||||
|
||||
/// 将行分组为块,每块包含指定数量的行
|
||||
List<HighlightDocumentChunk> _buildChunks(HighlightTextDocument document, int chunkLines) {
|
||||
final chunks = <HighlightDocumentChunk>[];
|
||||
final allLines = document.lines;
|
||||
|
||||
for (var i = 0; i < allLines.length; i += chunkLines) {
|
||||
final endIndex = min(i + chunkLines, allLines.length);
|
||||
chunks.add(HighlightDocumentChunk(
|
||||
startLineIndex: i,
|
||||
endLineIndex: endIndex,
|
||||
));
|
||||
}
|
||||
|
||||
return chunks.isEmpty ? [HighlightDocumentChunk(startLineIndex: 0, endLineIndex: 0)] : chunks;
|
||||
}
|
||||
|
||||
/// 为指定块构建 InlineSpan 列表
|
||||
List<InlineSpan> _buildSpansForChunk(
|
||||
BuildContext context,
|
||||
HighlightTextDocument document,
|
||||
HighlightDocumentChunk chunk,
|
||||
) {
|
||||
final spans = <InlineSpan>[];
|
||||
|
||||
for (var i = chunk.startLineIndex; i < chunk.endLineIndex; i++) {
|
||||
if (i >= document.lines.length) break;
|
||||
|
||||
spans.addAll(document.buildSpansForLine(context, i));
|
||||
|
||||
// 在行之间添加换行符
|
||||
if (i < chunk.endLineIndex - 1) {
|
||||
spans.add(TextSpan(text: '\n', style: document.rootStyle));
|
||||
}
|
||||
}
|
||||
|
||||
return spans;
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本块的定义,用于虚拟化渲染
|
||||
class HighlightDocumentChunk {
|
||||
final int startLineIndex;
|
||||
final int endLineIndex;
|
||||
|
||||
HighlightDocumentChunk({
|
||||
required this.startLineIndex,
|
||||
required this.endLineIndex,
|
||||
});
|
||||
|
||||
int get lineCount => endLineIndex - startLineIndex;
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import 'package:window_manager/window_manager.dart';
|
||||
import '../component/json/json_text.dart';
|
||||
import '../component/search/highlight_text.dart';
|
||||
import '../component/search/search_controller.dart';
|
||||
import '../component/search/virtualized_highlight_text.dart';
|
||||
import '../toolbox/encoder.dart';
|
||||
|
||||
///请求响应的body部分
|
||||
@@ -688,16 +689,16 @@ class _BodyState extends State<_Body> {
|
||||
}) {
|
||||
final language = _languageForViewType(type, message);
|
||||
final formattedText = language != null ? _formatTextBody(type, text) : text;
|
||||
// final showVirtualized = formattedText.length > _virtualizedThreshold;
|
||||
// if (showVirtualized) {
|
||||
// return VirtualizedHighlightText(
|
||||
// text: formattedText,
|
||||
// language: language,
|
||||
// contextMenuBuilder: contextMenu,
|
||||
// searchController: widget.searchController,
|
||||
// scrollController: widget.scrollController,
|
||||
// );
|
||||
// }
|
||||
final showVirtualized = formattedText.length > _virtualizedThreshold;
|
||||
if (showVirtualized) {
|
||||
return VirtualizedHighlightText(
|
||||
text: formattedText,
|
||||
language: language,
|
||||
contextMenuBuilder: contextMenu,
|
||||
searchController: widget.searchController,
|
||||
scrollController: widget.scrollController,
|
||||
);
|
||||
}
|
||||
|
||||
return HighlightTextWidget(
|
||||
language: language,
|
||||
|
||||
82
test/virtualized_highlight_text_test.dart
Normal file
82
test/virtualized_highlight_text_test.dart
Normal file
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:proxypin/ui/component/search/search_controller.dart';
|
||||
import 'package:proxypin/ui/component/search/virtualized_highlight_text.dart';
|
||||
import 'package:scrollable_positioned_list_nic/scrollable_positioned_list_nic.dart';
|
||||
|
||||
void main() {
|
||||
group('VirtualizedHighlightText', () {
|
||||
testWidgets('renders through ScrollablePositionedList and updates match counts', (tester) async {
|
||||
final controller = SearchTextController();
|
||||
BuildContext? hostContext;
|
||||
final text = List.generate(260, (index) => index == 180 ? 'line $index target' : 'line $index').join('\n');
|
||||
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Builder(builder: (context) {
|
||||
hostContext = context;
|
||||
return VirtualizedHighlightText(
|
||||
text: text,
|
||||
language: 'javascript',
|
||||
searchController: controller,
|
||||
chunkLines: 80,
|
||||
);
|
||||
}),
|
||||
),
|
||||
));
|
||||
|
||||
controller.patternController.text = 'target';
|
||||
controller.showSearchOverlay(hostContext!, top: 0, right: 0);
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(ScrollablePositionedList), findsOneWidget);
|
||||
expect(controller.totalMatchCount.value, 1);
|
||||
|
||||
await _disposeController(tester, controller);
|
||||
});
|
||||
|
||||
testWidgets('scrolls to the active match line when navigating', (tester) async {
|
||||
final controller = SearchTextController();
|
||||
BuildContext? hostContext;
|
||||
final text = List.generate(
|
||||
320,
|
||||
(index) => index == 24 || index == 260 ? 'line $index target' : 'line $index',
|
||||
).join('\n');
|
||||
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Builder(builder: (context) {
|
||||
hostContext = context;
|
||||
return VirtualizedHighlightText(
|
||||
text: text,
|
||||
language: 'javascript',
|
||||
searchController: controller,
|
||||
chunkLines: 80,
|
||||
);
|
||||
}),
|
||||
),
|
||||
));
|
||||
|
||||
controller.patternController.text = 'target';
|
||||
controller.showSearchOverlay(hostContext!, top: 0, right: 0);
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
controller.moveNext();
|
||||
await tester.pump();
|
||||
|
||||
expect(controller.currentMatchIndex.value, 1);
|
||||
|
||||
await _disposeController(tester, controller);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _disposeController(WidgetTester tester, SearchTextController controller) async {
|
||||
controller.closeSearch();
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump();
|
||||
controller.dispose();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user