fix(app): 优化平板帧率与评分任务交互

性能与页面切换:将公共背景图预缩放并预合成透明度,避免大图重复解码;根据 ELE2-W00 实测将 Android 渲染后端从 Impeller/Vulkan 切换到更快的 OpenGL;评分路由先整体显示加载页,再并行获取评分表和完成角色控制器初始化,返回时避开转场刷新任务列表。

任务交互:拆分按钮外观可用性和打开期间的点击拦截,防止点击一项任务时其他任务按钮同步变灰;重试画板时复用忙碌保护,避免重复触发。补齐加载及异常提示的英文文案。

验证:新增导航加载态、背景资源及任务按钮状态回归测试;flutter analyze --no-pub 无问题,flutter test --no-pub 全部 99 项通过;在 ELE2-W00 上覆盖安装 Release APK,验证任务查看与返回,任务列表滑动帧间隔主要约 11.1 ms。
This commit is contained in:
ltx 2026-10-22 15:45:57 +08:00
parent f56ed293e8
commit 9ad8f955e0
11 changed files with 366 additions and 141 deletions

View File

@ -5,9 +5,12 @@
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round">
<!-- On the ELE2-W00, Impeller/Vulkan took ~43 ms (p90) to rasterize
the task list; Flutter's OpenGL renderer took ~4-7 ms in the
same release-mode gesture. Keep the measured faster backend. -->
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="true" />
android:value="false" />
<activity
android:name=".SplashActivity"
android:exported="true"

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

View File

@ -210,6 +210,7 @@ abstract final class AppStrings {
'仅展示最近的待执裁赛事,可选择任意场次评分。':
'Only the next pending event is shown. Choose any session to score.',
'正在加载裁判任务…': 'Loading judging tasks…',
'正在加载评分表…': 'Loading score sheet…',
'暂时无法获取执裁任务': 'Could not load judging tasks',
'重新加载': 'Reload',
'当前没有待评分赛事任务': 'No pending judging tasks',
@ -404,6 +405,14 @@ abstract final class AppStrings {
'请刷新任务后重试': 'Refresh tasks and try again',
'裁判席位已更新': 'Judge seat updated',
'无法打开评分表': 'Unable to open score sheet',
'正在打开评分表': 'Opening score sheet',
'评分表加载完成后即可返回任务列表':
'You can return to tasks after the score sheet finishes loading',
'评分表准备失败,请返回任务列表后重试':
'Could not prepare the score sheet. Return to tasks and try again.',
'修正任务暂不可用,请刷新任务后重试':
'Correction task unavailable. Refresh tasks and try again.',
'登录已失效,请重新登录': 'Session expired. Please sign in again.',
'无法打开修正任务': 'Unable to open correction task',
'该任务已失效或后台已撤回,请刷新后重试':
'This task expired or was withdrawn. Refresh and try again.',

View File

@ -29,39 +29,32 @@ abstract final class AppPages {
),
GetPage(
name: AppRoutes.home,
page: () =>
const AnimatedAppBackground(gridOpacity: .11, child: HomePage()),
page: () => const AnimatedAppBackground(child: HomePage()),
transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 200),
),
GetPage(
name: AppRoutes.scoring,
page: () =>
const AnimatedAppBackground(gridOpacity: .11, child: ScoringPage()),
page: () => const AnimatedAppBackground(child: ScoringPage()),
binding: ScoringBinding(),
transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 200),
),
GetPage(
name: AppRoutes.messages,
page: () =>
const AnimatedAppBackground(gridOpacity: .11, child: MessagesPage()),
page: () => const AnimatedAppBackground(child: MessagesPage()),
transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 200),
),
GetPage(
name: AppRoutes.corrections,
page: () => const AnimatedAppBackground(
gridOpacity: .11,
child: CorrectionsPage(),
),
page: () => const AnimatedAppBackground(child: CorrectionsPage()),
transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 200),
),
GetPage(
name: AppRoutes.results,
page: () =>
const AnimatedAppBackground(gridOpacity: .11, child: ResultsPage()),
page: () => const AnimatedAppBackground(child: ResultsPage()),
transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 200),
),

View File

@ -4,21 +4,13 @@ import 'package:flutter/material.dart';
/// Shared static background for business pages. It performs no per-frame work.
class AnimatedAppBackground extends StatelessWidget {
const AnimatedAppBackground({
super.key,
required this.child,
this.gridOpacity = .80,
});
const AnimatedAppBackground({super.key, required this.child});
final Widget child;
final double gridOpacity;
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
final decodedWidth = (mediaQuery.size.width * mediaQuery.devicePixelRatio)
.ceil()
.clamp(1, 2560);
return DecoratedBox(
decoration: const BoxDecoration(
@ -51,12 +43,14 @@ class AnimatedAppBackground extends StatelessWidget {
child: IgnorePointer(
child: RepaintBoundary(
child: Image.asset(
'assets/images/huaban63273919611.png',
// This asset is already downsampled with the .11 opacity
// baked into its alpha channel. No per-route opacity layer
// or large source-image decode is needed.
'assets/images/background_wave_2048.png',
fit: BoxFit.contain,
alignment: Alignment.bottomCenter,
cacheWidth: decodedWidth,
filterQuality: FilterQuality.medium,
opacity: AlwaysStoppedAnimation<double>(gridOpacity),
filterQuality: FilterQuality.low,
excludeFromSemantics: true,
gaplessPlayback: true,
),
),

View File

@ -60,6 +60,13 @@ abstract final class TaskQueuePolicy {
return -1;
}
/// Stable task availability for button appearance. A brief navigation
/// operation must not make unrelated task buttons look disabled.
static bool isTaskActionAvailable({
required int index,
required int pendingSubmissionIndex,
}) => pendingSubmissionIndex < 0 || index == pendingSubmissionIndex;
/// An unconfirmed submission must be resolved before switching tasks, but
/// ordinary unfinished tasks do not lock later entries in the queue.
static bool canOpenTask({
@ -68,7 +75,10 @@ abstract final class TaskQueuePolicy {
required bool opening,
}) =>
!opening &&
(pendingSubmissionIndex < 0 || index == pendingSubmissionIndex);
isTaskActionAvailable(
index: index,
pendingSubmissionIndex: pendingSubmissionIndex,
);
static int _compareCandidates<T>(
TaskQueueCandidate<T> a,

View File

@ -221,25 +221,39 @@ class _HomePageState extends State<HomePage> {
blockingIndex >= 0 &&
entry.$1 != blockingIndex,
onTap:
!TaskQueuePolicy.canOpenTask(
!TaskQueuePolicy.isTaskActionAvailable(
index: entry.$1,
pendingSubmissionIndex: blockingIndex,
opening: _openingEntryId != null,
)
? null
: statuses[entry.$1] ==
ScoringTaskStatus
.submittedAnnotationPending
.label
? () => _retryAnnotation(
_remoteTasks[entry.$1]
.entry
.sessionEntryId,
)
: () => _openTask(
entry.$1,
_remoteTasks[entry.$1],
),
: () {
if (!TaskQueuePolicy.canOpenTask(
index: entry.$1,
pendingSubmissionIndex: blockingIndex,
opening: _openingEntryId != null,
)) {
return;
}
if (statuses[entry.$1] ==
ScoringTaskStatus
.submittedAnnotationPending
.label) {
unawaited(
_retryAnnotation(
_remoteTasks[entry.$1]
.entry
.sessionEntryId,
),
);
} else {
unawaited(
_openTask(
entry.$1,
_remoteTasks[entry.$1],
),
);
}
},
),
),
),
@ -325,8 +339,13 @@ class _HomePageState extends State<HomePage> {
}
Future<void> _retryAnnotation(String sessionEntryId) async {
await Get.find<BackgroundSyncService>().retryAnnotation(sessionEntryId);
if (mounted) setState(() {});
_openingEntryId = sessionEntryId;
try {
await Get.find<BackgroundSyncService>().retryAnnotation(sessionEntryId);
if (mounted) setState(() {});
} finally {
_openingEntryId = null;
}
}
Future<void> _openResults() async {
@ -387,77 +406,21 @@ class _HomePageState extends State<HomePage> {
_RemoteTask remoteTask, {
bool correction = false,
}) async {
setState(() => _openingEntryId = remoteTask.entry.sessionEntryId);
_openingEntryId = remoteTask.entry.sessionEntryId;
try {
final sheetResult = await _api.loadSheet(
remoteTask.entry.sessionEntryId,
requireFresh: correction || remoteTask.entry.scoreStatus == 3,
// Show a complete route immediately. Its loading surface remains visible
// until the sheet, local context and role-specific controller are ready.
final preparation = _prepareScoringTask(index, remoteTask, correction);
final routeResult = await Get.toNamed(
AppRoutes.scoring,
arguments: preparation,
);
final session = _auth.session!;
final sheet = sheetResult.data;
if (correction && (!sheet.isCorrection || !sheet.canEdit)) {
AppNotice.show('修正任务暂不可用', sheet.lockReason ?? '请刷新任务后重试');
return;
}
final assignmentChanged = !RemoteScoringAdapter.sheetRoleMatchesSession(
sheet,
remoteTask.session,
);
final profile = RemoteScoringAdapter.accountFor(
sheet,
userId: session.user.id,
userName: session.user.name,
);
final task = RemoteScoringAdapter.taskFor(
remoteTask.session,
remoteTask.entry,
sheet,
);
if (Get.isRegistered<ScoringController>()) {
await Get.delete<ScoringController>(force: true);
}
await _storage.settings.putAll({
'judgeId': profile.account,
'judgeDisplayName': session.user.name,
'judgeMobile': session.user.mobile,
'judgeType': profile.judgeType.name,
'judgeRoleKey': profile.storageKey,
'activeTaskIndex': index,
ScoringStorageKeys.activeTaskSetting: task.encode(),
ScoringStorageKeys.activePlayerIdSetting: task.teamId,
ScoringStorageKeys.prescribedEntryTypeSetting:
task.prescribedEntryType.name,
ScoringStorageKeys.activeCompetitionLabelSetting: task.project,
ScoringStorageKeys.activeAthleteLabelSetting: task.athleteLabel,
ScoringStorageKeys.activeTaskVariantSetting: task.taskVariant,
ScoringStorageKeys.apiActiveSheetSetting: sheet.encode(),
ScoringStorageKeys.apiActiveSessionSetting: jsonEncode(
remoteTask.session.toJson(),
),
ScoringStorageKeys.apiActiveEntrySetting: jsonEncode(
remoteTask.entry.toJson(),
),
ScoringStorageKeys.apiResumeScoringSetting: true,
'judge-api-last-entry-${session.user.id}':
remoteTask.entry.sessionEntryId,
});
await _storage.settings.flush();
final scoringController = Get.put(
ScoringController(
Get.find<ScoreRepository>(),
Get.find<LocalStorageService>(),
),
);
await scoringController.initialize();
if (assignmentChanged) {
AppNotice.show(
'裁判席位已更新',
'已按最新评分表切换为${RemoteScoringAdapter.roleNameForType(sheet.header.judgeRoleType)} · ${sheet.header.judgeRole}',
);
}
final routeResult = await Get.toNamed(AppRoutes.scoring);
final continueNext = routeResult == true;
final correctionEntryId = routeResult is String ? routeResult : null;
// Get.toNamed completes when the route starts popping. Keep storage and
// task-list refresh work off the 200 ms reverse transition.
await Future<void>.delayed(const Duration(milliseconds: 220));
if (!mounted || _auth.session == null) return;
await _storage.settings.put(
ScoringStorageKeys.apiResumeScoringSetting,
false,
@ -475,7 +438,87 @@ class _HomePageState extends State<HomePage> {
} on ApiException catch (error) {
AppNotice.show('无法打开评分表', error.message);
} finally {
if (mounted) setState(() => _openingEntryId = null);
_openingEntryId = null;
}
}
Future<void> _prepareScoringTask(
int index,
_RemoteTask remoteTask,
bool correction,
) async {
final sheetFuture = _api.loadSheet(
remoteTask.entry.sessionEntryId,
requireFresh: correction || remoteTask.entry.scoreStatus == 3,
);
// Fetch concurrently with the route animation, but do not construct the
// large role-specific scoring tree during the first transition frames.
await Future.wait<void>([
sheetFuture.then<void>((_) {}),
Future<void>.delayed(const Duration(milliseconds: 240)),
]);
final sheetResult = await sheetFuture;
final session = _auth.session;
if (session == null) throw const ApiException('登录已失效,请重新登录');
final sheet = sheetResult.data;
if (correction && (!sheet.isCorrection || !sheet.canEdit)) {
throw ApiException(sheet.lockReason ?? '修正任务暂不可用,请刷新任务后重试');
}
final assignmentChanged = !RemoteScoringAdapter.sheetRoleMatchesSession(
sheet,
remoteTask.session,
);
final profile = RemoteScoringAdapter.accountFor(
sheet,
userId: session.user.id,
userName: session.user.name,
);
final task = RemoteScoringAdapter.taskFor(
remoteTask.session,
remoteTask.entry,
sheet,
);
if (Get.isRegistered<ScoringController>()) {
await Get.delete<ScoringController>(force: true);
}
await _storage.settings.putAll({
'judgeId': profile.account,
'judgeDisplayName': session.user.name,
'judgeMobile': session.user.mobile,
'judgeType': profile.judgeType.name,
'judgeRoleKey': profile.storageKey,
'activeTaskIndex': index,
ScoringStorageKeys.activeTaskSetting: task.encode(),
ScoringStorageKeys.activePlayerIdSetting: task.teamId,
ScoringStorageKeys.prescribedEntryTypeSetting:
task.prescribedEntryType.name,
ScoringStorageKeys.activeCompetitionLabelSetting: task.project,
ScoringStorageKeys.activeAthleteLabelSetting: task.athleteLabel,
ScoringStorageKeys.activeTaskVariantSetting: task.taskVariant,
ScoringStorageKeys.apiActiveSheetSetting: sheet.encode(),
ScoringStorageKeys.apiActiveSessionSetting: jsonEncode(
remoteTask.session.toJson(),
),
ScoringStorageKeys.apiActiveEntrySetting: jsonEncode(
remoteTask.entry.toJson(),
),
ScoringStorageKeys.apiResumeScoringSetting: true,
'judge-api-last-entry-${session.user.id}':
remoteTask.entry.sessionEntryId,
});
await _storage.settings.flush();
final scoringController = Get.put(
ScoringController(
Get.find<ScoreRepository>(),
Get.find<LocalStorageService>(),
),
);
await scoringController.initialize();
if (assignmentChanged) {
AppNotice.show(
'裁判席位已更新',
'已按最新评分表切换为${RemoteScoringAdapter.roleNameForType(sheet.header.judgeRoleType)} · ${sheet.header.judgeRole}',
);
}
}

View File

@ -8,6 +8,7 @@ import '../../../app/localization/app_text.dart';
import '../../../app/routes/app_routes.dart';
import '../../../app/theme/app_theme.dart';
import '../../../common/widgets/app_gradient_button.dart';
import '../../../common/widgets/page_loading_view.dart';
import '../../../core/models/elite_scoring_rule.dart';
import '../../../core/models/judge_account.dart';
import '../../../core/network/api_client.dart';
@ -35,6 +36,8 @@ class ScoringPage extends StatefulWidget {
class _ScoringPageState extends State<ScoringPage> with WidgetsBindingObserver {
ScoringController get controller => Get.find<ScoringController>();
bool _launchReady = true;
String? _launchError;
List<_CorrectionTask> _correctionTasks = const [];
Future<void>? _correctionLoadFuture;
Timer? _correctionTimer;
@ -44,8 +47,34 @@ class _ScoringPageState extends State<ScoringPage> with WidgetsBindingObserver {
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) => _loadCorrections());
_correctionTimer = Timer.periodic(
final preparation = Get.arguments;
if (preparation is Future<void>) {
_launchReady = false;
preparation.then(
(_) {
if (!mounted) return;
setState(() => _launchReady = true);
_startCorrectionUpdates();
},
onError: (Object error, StackTrace _) {
if (!mounted) return;
setState(() {
_launchError = error is ApiException
? error.message
: '评分表准备失败,请返回任务列表后重试';
});
},
);
} else {
_startCorrectionUpdates();
}
}
void _startCorrectionUpdates() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _loadCorrections();
});
_correctionTimer ??= Timer.periodic(
const Duration(seconds: 30),
(_) => _loadCorrections(),
);
@ -66,38 +95,52 @@ class _ScoringPageState extends State<ScoringPage> with WidgetsBindingObserver {
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
canPop: _launchError != null,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _backToTasks();
if (didPop) return;
if (!_launchReady) {
AppNotice.show('正在打开评分表', '评分表加载完成后即可返回任务列表');
return;
}
_backToTasks();
},
child: Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
top: true,
bottom: false,
child: Obx(() {
if (!controller.isReady.value) {
return const Center(child: CircularProgressIndicator());
}
// Keep the readiness observer small. Score changes are observed by
// dedicated descendants, so a single tap no longer rebuilds the
// full-screen animated background and page structure.
return _ReadyScoringPage(
controller: controller,
onBack: _backToTasks,
onCorrection: _openCorrectionTasks,
correctionTaskCount: _correctionTasks.length,
onSubmit: _submit,
onServerSubmissionConfirmed: () =>
_afterSuccessfulSubmission(controller.isCorrectionMode.value),
onDrawingBoard: _showDrawingBoard,
);
}),
),
),
child: _launchError != null
? _ScoringLaunchError(
message: _launchError!,
onBack: () => Get.back(),
)
: !_launchReady
? const PageLoadingView(label: '正在加载评分表…')
: _buildReadyPage(),
);
}
Widget _buildReadyPage() => Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
top: true,
bottom: false,
child: Obx(() {
if (!controller.isReady.value) {
return const Center(child: CircularProgressIndicator());
}
// Keep the readiness observer small. Score changes are observed by
// dedicated descendants, so a single tap no longer rebuilds the
// full-screen animated background and page structure.
return _ReadyScoringPage(
controller: controller,
onBack: _backToTasks,
onCorrection: _openCorrectionTasks,
correctionTaskCount: _correctionTasks.length,
onSubmit: _submit,
onServerSubmissionConfirmed: () =>
_afterSuccessfulSubmission(controller.isCorrectionMode.value),
onDrawingBoard: _showDrawingBoard,
);
}),
),
);
Future<void> _submit() async {
if (controller.isSubmitPending.value) {
await controller.retryPendingSubmission();
@ -449,6 +492,48 @@ class _CorrectionTaskDialog extends StatelessWidget {
);
}
class _ScoringLaunchError extends StatelessWidget {
const _ScoringLaunchError({required this.message, required this.onBack});
final String message;
final VoidCallback onBack;
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.cloud_off_outlined,
size: 44,
color: AppColors.primary,
),
const SizedBox(height: 16),
const AppText(
'无法打开评分表',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
AppText(message, textAlign: TextAlign.center),
const SizedBox(height: 22),
AppGradientButton(
onPressed: onBack,
minimumSize: const Size(160, 48),
child: const AppText('返回任务列表'),
),
],
),
),
),
),
);
}
class _ReadyScoringPage extends StatefulWidget {
const _ReadyScoringPage({
required this.controller,

View File

@ -23,6 +23,10 @@ void main() {
'Level 3 difficulty: 4 items',
);
expect(AppStrings.translate('2026上海公开赛', locale: english), '2026上海公开赛');
expect(
AppStrings.translate('正在加载评分表…', locale: english),
'Loading score sheet…',
);
expect(AppStrings.translate('登录系统', locale: const Locale('zh')), '登录系统');
},
);

View File

@ -0,0 +1,57 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
import 'package:yd_pattern_scoring_system/common/widgets/animated_app_background.dart';
import 'package:yd_pattern_scoring_system/core/network/api_client.dart';
import 'package:yd_pattern_scoring_system/modules/scoring/views/scoring_page.dart';
void main() {
tearDown(Get.reset);
testWidgets('评分路由先完整显示加载页,失败后提供返回入口', (tester) async {
final preparation = Completer<void>();
await tester.pumpWidget(
GetMaterialApp(
home: Scaffold(
body: TextButton(
onPressed: () =>
Get.toNamed('/scoring', arguments: preparation.future),
child: const Text('打开'),
),
),
getPages: [GetPage(name: '/scoring', page: ScoringPage.new)],
),
);
await tester.tap(find.text('打开'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 250));
expect(find.byType(ScoringPage), findsOneWidget);
expect(find.text('正在加载评分表…'), findsOneWidget);
expect(find.text('无法打开评分表'), findsNothing);
preparation.completeError(const ApiException('网络暂不可用'));
await tester.pump();
expect(find.text('网络暂不可用'), findsOneWidget);
expect(find.text('返回任务列表'), findsOneWidget);
await tester.tap(find.text('返回任务列表'));
await tester.pump(const Duration(milliseconds: 250));
expect(find.text('打开'), findsOneWidget);
});
testWidgets('业务页背景使用已优化的单张静态图', (tester) async {
await tester.pumpWidget(
const MaterialApp(home: AnimatedAppBackground(child: SizedBox())),
);
final image = tester.widget<Image>(find.byType(Image));
expect(image.image, isA<AssetImage>());
expect(
(image.image as AssetImage).assetName,
'assets/images/background_wave_2048.png',
);
expect(image.opacity, isNull);
});
}

View File

@ -56,6 +56,33 @@ void main() {
);
});
test('打开某项任务时其他按钮保持原外观,但重复点击不会再次打开', () {
for (final index in [0, 1, 2]) {
expect(
TaskQueuePolicy.isTaskActionAvailable(
index: index,
pendingSubmissionIndex: -1,
),
isTrue,
);
expect(
TaskQueuePolicy.canOpenTask(
index: index,
pendingSubmissionIndex: -1,
opening: true,
),
isFalse,
);
}
expect(
TaskQueuePolicy.isTaskActionAvailable(
index: 0,
pendingSubmissionIndex: 1,
),
isFalse,
);
});
test('秩序册全局顺序优先于组别场次顺序', () {
final values = TaskQueuePolicy.nearestPendingEvent([
_candidate('A-1', 'event', 0, 1, false, 9, scheduleOrder: 1),