Fix duplicate launch screen and native splash exit flashing

This commit is contained in:
Codex
2026-10-13 22:52:25 +08:00
parent 204e945b2e
commit 57eb2c0505
10 changed files with 128 additions and 79 deletions
+3 -3
View File
@@ -1,8 +1,8 @@
# TPSchedule
学生课表 APP,Flutter 实现,目前接入电子科技大学研究生系统。Android 0.5.0+8,Android 7.0 及以上。
学生课表 APP,Flutter 实现,目前接入电子科技大学研究生系统。Android 0.5.1+9,Android 7.0 及以上。
使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色,初始化完成后直接进入课表,无额外等待。Android12+ 原生启动屏使用同色纯蓝背景及图标,Flutter 接续显示蓝色渐变。
使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
三个图标标签页:课表、考试、个人设置。界面统一蓝色,三个页面标题对齐。透明蓝色液态玻璃风格悬浮 Dock 缩窄至264,选中滑块为灰色玻璃,图标为实心蓝色,静止边距为3。支持点击图标或拖动选中滑块切换;按压和拖动时放大、松手弹簧回弹,支持的渲染模式启用实时背景折射。页面切换采用短距离移动与淡入淡出,并保留每页滚动位置。页面本身不支持左右滑动切换。课表和考试页面向下滚动时 Dock 用360ms过渡滑出屏幕底部,向上滚动恢复;个人设置页面保持显示。系统“减少动画”开启时减少切页与回弹动画。
@@ -51,4 +51,4 @@ flutter test
flutter analyze
```
仓库根目录运行 `./scripts/build-android.ps1` 构建。验证记录:[0.3.2蓝色图标修正](docs/rschedule-0.3.2-validation.md)、[0.3.1自动读取与玻璃动画修正](docs/rschedule-0.3.1-validation.md)、[0.3.0登录、考试和动画](docs/rschedule-0.3-validation.md)。Windows模拟器可仅在测试启动时使用软件渲染并关闭Impeller;交付APK保留手机默认渲染配置。历史验证:[0.2.1](docs/blue-glass-and-course-length.md)、[0.2.0](docs/riverside-revision-validation.md)、[首版](docs/android-mvp-validation.md)。
仓库根目录运行 `./scripts/build-android.ps1` 构建。验证记录:[0.5.1启动闪烁修复](docs/tpschedule-0.5.1-validation.md)、[0.3.2蓝色图标修正](docs/rschedule-0.3.2-validation.md)、[0.3.1自动读取与玻璃动画修正](docs/rschedule-0.3.1-validation.md)、[0.3.0登录、考试和动画](docs/rschedule-0.3-validation.md)。Windows模拟器可仅在测试启动时使用软件渲染并关闭Impeller;交付APK保留手机默认渲染配置。历史验证:[0.2.1](docs/blue-glass-and-course-length.md)、[0.2.0](docs/riverside-revision-validation.md)、[首版](docs/android-mvp-validation.md)。
+25
View File
@@ -0,0 +1,25 @@
# TPSchedule Android 0.5.1+9:启动闪烁修复
日期:2026-10-13。
0.5.0先显示Android原生启动屏,再通过FutureBuilder构建Flutter品牌页,最后AnimatedSwitcher淡出220ms。Future即使已完成,首次构建仍会进入等待分支;重复快速启动时,第二层品牌页容易表现为短暂闪屏。原实现的回归测试已复现这两个分支。
0.5.1恢复此前0.4.0的正常启动方式:先完成controller.initialize,再一次性runApp。原生启动屏保持到首个真正的应用帧,不再额外构建Flutter品牌页或淡出。后台返回同一实例不重新初始化;初始化失败仍可进入原有恢复界面。
连续三次冷启动后,录屏还复现后台返回时的无标志亮蓝色预览。已将NormalTheme窗口、状态栏和导航栏的底色改为与课表canvas一致的#F3F7FF,并使用深色系统图标;浅深资源均同步。仅改窗口底色仍不足以控制系统预览;日志确认还有Android SplashScreenView的退场动画。因此API31及以上使用官方setOnExitAnimationListener,在实际应用首帧完成后立即remove系统启动视图,去掉多余退场动画。LaunchTheme及Android12+系统启动屏保持原蓝色和标志,较旧安卓不调用新API。
验证期间曾尝试首帧延后/释放方案,但软件渲染模拟器实测需要额外触碰才进入应用;普通widget测试会主动pump且替换帧调度,不能代表原生表现。最终放弃此方案,沿用正常runApp引导所安排的真实引擎帧,不依赖warm-up帧呈现或额外用户操作。
自动测试覆盖等待真实初始化完成后启动、缓存数据首次绘制、后台返回、失败后的恢复界面。最终完整Flutter测试141项通过,使用--enable-impeller;静态分析无问题。
参考:[Android启动屏衔接](https://docs.flutter.dev/platform-integration/android/splash-screen)、[Flutter首帧延后API](https://api.flutter.dev/flutter/widgets/WidgetsBinding/deferFirstFrame.html)。已核对Flutter本地renderer、scheduler及Android embedding源代码。
应用ID仍为com.tpschedule.app,签名配置不变;图标、原生启动配色和通透Dock不改。0.5.0记录保留为历史验证。
## 原生检查
API36模拟器覆盖安装并进入保存的课表,无需触碰屏幕。最终发布包的冷启动和后台返回录屏位于dist/screenshots/tpschedule-startup-fixed.mp4。普通启动不再叠加Flutter品牌页;后台返回的系统预览由约0.3–0.4秒退场缩短为录屏中的约0.1秒,系统进入动画本身仍由Android控制,不能宣称每台手机完全没有任何系统预览帧。该检查使用软件渲染,具体手机效果还需实机确认。
发布包和调试包均构建成功并通过原证书指纹检查;新旧证书及公钥完全一致。源码审查确认初始化失败后的恢复界面与较旧安卓API兼容性保留。
发布APK SHA256:D8F4C15E9D3EF02AEF98DDB2BD032E2DF4B7A8166F79C34D524CCC7C08330D29。
@@ -1,10 +1,20 @@
package com.tpschedule.app
import android.os.Build
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() {
private var calendar: CalendarBridge? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Once Flutter has drawn, remove the system preview immediately.
// Its default blue exit fade can flash over an already ready app.
splashScreen.setOnExitAnimationListener { view -> view.remove() }
}
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
calendar = CalendarBridge(this, flutterEngine.dartExecutor.binaryMessenger)
@@ -8,10 +8,10 @@
<item name="android:windowLightNavigationBar">false</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/tpschedule_blue</item>
<item name="android:statusBarColor">@color/tpschedule_blue</item>
<item name="android:navigationBarColor">@color/tpschedule_blue</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar">false</item>
<item name="android:windowBackground">@color/tpschedule_surface</item>
<item name="android:statusBarColor">@color/tpschedule_surface</item>
<item name="android:navigationBarColor">@color/tpschedule_surface</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowLightNavigationBar">true</item>
</style>
</resources>
@@ -8,10 +8,10 @@
<item name="android:windowLightNavigationBar">false</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/tpschedule_blue</item>
<item name="android:statusBarColor">@color/tpschedule_blue</item>
<item name="android:navigationBarColor">@color/tpschedule_blue</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar">false</item>
<item name="android:windowBackground">@color/tpschedule_surface</item>
<item name="android:statusBarColor">@color/tpschedule_surface</item>
<item name="android:navigationBarColor">@color/tpschedule_surface</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowLightNavigationBar">true</item>
</style>
</resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="tpschedule_surface">#F3F7FF</color>
<color name="tpschedule_blue">#38B3FF</color>
</resources>
+21 -43
View File
@@ -1,48 +1,26 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'tp_launch_page.dart';
/// Displays the brand during real initialization, without a minimum wait.
class TpStartup extends StatelessWidget {
const TpStartup({super.key, required this.ready, required this.child});
final Future<void> ready;
final Widget child;
@override
Widget build(BuildContext context) => FutureBuilder<void>(
future: ready,
builder: (context, snapshot) {
final complete = snapshot.connectionState == ConnectionState.done;
final reduceMotion =
MediaQuery.maybeOf(context)?.disableAnimations ??
WidgetsBinding
.instance
.platformDispatcher
.accessibilityFeatures
.disableAnimations;
return AnimatedSwitcher(
duration: reduceMotion
? Duration.zero
: const Duration(milliseconds: 220),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: complete
? AnnotatedRegion<SystemUiOverlayStyle>(
key: const ValueKey('tp-ready'),
value: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: const Color(0xFFF3F7FF),
systemNavigationBarIconBrightness: Brightness.dark,
),
child: child,
)
: const MaterialApp(
key: ValueKey('tp-launch'),
debugShowCheckedModeBanner: false,
home: TpLaunchPage(),
),
);
},
/// The native launch screen remains until runApp submits the initialized app.
/// Use normal bootstrap instead of a second splash or deferred warm-up frames.
Future<void> launchApplication({
required Future<void> ready,
required Widget child,
}) async {
try {
await ready;
} catch (_) {
// AppController exposes initialization failures in its recovery UI.
// Preserve access to that UI instead of leaving the native launch visible.
}
runApp(
AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: const Color(0xFFF3F7FF),
systemNavigationBarIconBrightness: Brightness.dark,
),
child: child,
),
);
}
+6 -8
View File
@@ -34,14 +34,12 @@ Future<void> main() async {
AndroidCalendarService(MethodCalendarGateway()),
DateTime.now,
);
runApp(
TpStartup(
ready: controller.initialize(),
child: buildApplication(
controller,
notificationSelection: selection,
school: SchoolSession(),
),
await launchApplication(
ready: controller.initialize(),
child: buildApplication(
controller,
notificationSelection: selection,
school: SchoolSession(),
),
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: hepan_everyday
description: TPSchedule 学生课表
publish_to: none
version: 0.5.0+8
version: 0.5.1+9
environment:
sdk: ^3.13.5
dependencies:
+51 -14
View File
@@ -5,25 +5,62 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:hepan_everyday/app/tp_startup.dart';
void main() {
testWidgets('waiting initialization does not replace the native launch', (
tester,
) async {
await tester.pumpWidget(const SizedBox());
final ready = Completer<void>();
var startupFinished = false;
final startup = launchApplication(
ready: ready.future,
child: const MaterialApp(home: Text('已准备好课表')),
).then((_) => startupFinished = true);
await tester.pump();
expect(startupFinished, isFalse);
expect(tester.binding.sendFramesToEngine, isTrue);
expect(find.text('TPSchedule'), findsNothing);
expect(find.text('已准备好课表'), findsNothing);
ready.complete();
await startup;
await tester.pump();
expect(find.text('已准备好课表'), findsOneWidget);
expect(find.text('TPSchedule'), findsNothing);
expect(tester.binding.sendFramesToEngine, isTrue);
});
testWidgets('cached startup and resume never insert an extra launch frame', (
tester,
) async {
final ready = Future<void>.value();
await ready;
await launchApplication(
ready: ready,
child: const MaterialApp(home: Text('保存的课表')),
);
await tester.pump();
expect(find.text('保存的课表'), findsOneWidget);
expect(find.text('TPSchedule'), findsNothing);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
expect(find.text('保存的课表'), findsOneWidget);
expect(find.text('TPSchedule'), findsNothing);
});
testWidgets(
'startup shows brand while initializing and immediately proceeds when ready',
'initialization errors still show the recovery UI on first frame',
(tester) async {
final ready = Completer<void>();
await tester.pumpWidget(
TpStartup(
ready: ready.future,
child: const MaterialApp(home: Scaffold(body: Text('已准备好课表'))),
),
final startup = launchApplication(
ready: ready.future,
child: const MaterialApp(home: Text('恢复保存文件')),
);
ready.completeError(StateError('storage failed'));
await startup;
await tester.pump();
expect(find.text('TPSchedule'), findsOneWidget);
expect(find.text('已准备好课表'), findsNothing);
ready.complete();
await tester.pump();
await tester.pump();
expect(find.text('已准备好课表'), findsOneWidget);
await tester.pumpAndSettle();
expect(find.text('TPSchedule'), findsNothing);
expect(find.text('恢复保存文件'), findsOneWidget);
expect(tester.binding.sendFramesToEngine, isTrue);
expect(tester.takeException(), isNull);
},
);
}