没错,米饭的个人主页又又又更新了,这一次依旧是使用Flutter for Web构建。比起之前的版本,米饭这一次的个人主页加载更快并且动画更流畅啦~

由于Flutter编译的网页是纯静态文件,所以可以使用EdgeOne Pages或者Github Pages等类似服务实现无服务器部署,这一次米饭使用的是腾讯的EdgeOne Pages
动画效果预览
这一次所有页面都有适当的动画~切换页面的时候也会有颜色的改变

- 动图由于压缩的原因,似乎无法正常播放
大家可以直接访问米饭的个人主页查看效果:米饭的个人主页
使用GitHub Action实现自动部署EdgeOne Pages
使用Github Action可以让我们很方便的自动编译web文件,并且部署到EdgeOne Pages中,下面是米饭使用的action配置文件,大家可以参考~
name:Flutter Web Build & EdgeOne Deploy
on:
push:
branches:[main]
jobs:
build-and-deploy:
runs-on:ubuntu-latest
steps:
-name:Checkout Code
uses:actions/checkout@v4
with:
fetch-depth:0
-name:Set up Flutter
uses:subosito/flutter-action@v2
with:
channel:stable
flutter-version:'3.32.6'
-name:Install Dependencies
run:flutter pub get
-name:Set Permission
run:chmod +x build.sh
-name:Build Web Release
run:./build.sh --no-web-resources-cdn
env:
NODE_OPTIONS:'--max_old_space_size=8192'
-name:Deploy to EdgeOne Pages
run:npx edgeone pages deploy build/web -n 这里替换为你的pages名字 -t ${{ secrets.EDGEONE_API_TOKEN }}
env:
EDGEONE_API_TOKEN:${{ secrets.EDGEONE_API_TOKEN }}
YAML
注意其中Build Web Release步骤,因为米饭的个人主页会在编译后自动注入加载页面,所以写了独立的构建脚本,如果不需要注入加载页面,改为flutter本身的构建命令就可以啦flutter build web --no-web-resources-cdn
最后一步Deploy to EdgeOne Pages的时候,需要提前在action的secrets中配置好token内容,关于API的获取可以查看这里:API Token文档。并且修改这里替换为你的pages名字为对应名称,如果名称不存在则会自动创建。
不过需要注意的是,自动创建的pages默认加速大陆地区,如果你的域名没有备案就不可用。如果不需要加速大陆地区,需要手动在控制台创建新的pages,并填写对应的名称。
关于GitHub Actions的详细使用可以查看官网文档:GitHub Actions 部署文档
自动化添加加载页面 减少第一屏时间
众所周知,Flutter for web在打开网页时,会加载文件大小极大的canvaskit.wasm与main.dart.js,在这两个文件加载完成前,浏览器不会显示任何内容,导致第一屏时间非常长。所以我们可以添加一个加载动画来提升用户体验。

最简单的方法就是编辑编译后的index.html文件,在里面加入加载动画的HTML,但是这样的做法完全不自动化,不能实现EdgeOne Pages的自动部署,所以我们使用注入的方法~
在项目根目录创建文件夹tools,并创建文件inject_loading_screen.dart
import'dart:io';
import'dart:convert';
voidmain(List<String> args) {
// 检查是否使用了本地资源
finalboollocalAssets=args.contains('--local-assets');
print('Injecting loading screen (local assets: $localAssets)...');
// 1. 获取构建目录
finalprojectDir=Directory.current.path;
finalbuildDir='$projectDir/build/web';
// 2. 处理 index.html
finalindexFile=File('$buildDir/index.html');
if(!indexFile.existsSync()) {
print('Error: index.html not found in build directory!');
exit(1);
}
// 备份原始文件
indexFile.copySync('$buildDir/index.html.bak');
varindexContent=indexFile.readAsStringSync();
// 添加加载页面结构
constloadingScreenHTML='''
<!-- Flutter Loading Screen (Auto-injected) -->
<div id="loading-screen" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; background: white; z-index: 9999; transition: opacity 0.5s;">
<div class="spinner" style="border: 4px solid rgba(0, 0, 0, 0.1); width: 36px; height: 36px; border-radius: 50%; border-left-color: #09f; animation: spin 1s linear infinite;"></div>
<p style="margin-top: 20px; font-family: sans-serif;">Loading application...</p>
</div>
<div id="flutter-host"></div>
''';
// 修复正则表达式问题
indexContent=indexContent.replaceFirstMapped(
RegExp(r'<body(\s[^>]*)?>',caseSensitive:false),
(match)=>'<body${match.group(1) ?? ''}>$loadingScreenHTML'
);
// 添加CSS样式
constloadingScreenCSS='''
<style>
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
''';
indexContent=indexContent.replaceFirstMapped(
RegExp(r'<head>',caseSensitive:false),
(match)=>'<head>$loadingScreenCSS'
);
// 3. 处理 flutter_bootstrap.js
finalbootstrapFile=File('$buildDir/flutter_bootstrap.js');
if(!bootstrapFile.existsSync()) {
print('Error: flutter_bootstrap.js not found!');
exit(1);
}
// 备份原始文件
bootstrapFile.copySync('$buildDir/flutter_bootstrap.js.bak');
varbootstrapContent=bootstrapFile.readAsStringSync();
// 添加加载逻辑
StringloaderLogic='''
// ===== Loading Screen Logic (Auto-injected) =====
console.log('[LOADER] Starting Flutter loader...');
const loadingScreen = document.getElementById('loading-screen');
// 确保我们找到了加载页面
if (!loadingScreen) {
console.error('[LOADER] Loading screen element not found!');
}
// 更新加载状态
function updateLoadingText(text) {
console.log('[LOADER] ' + text);
if (loadingScreen) {
const textElement = loadingScreen.querySelector('p');
if (textElement) textElement.textContent = text;
}
}
// 隐藏加载页面的函数
function hideLoadingScreen() {
if (!loadingScreen) {
console.error('[LOADER] Cannot hide loading screen: element not found');
return;
}
console.log('[LOADER] Hiding loading screen');
// 添加淡出动画
loadingScreen.style.opacity = "0";
// 动画完成后移除元素
setTimeout(() => {
console.log('[LOADER] Removing loading screen');
loadingScreen.style.display = 'none';
// 可选:完全移除元素
// loadingScreen.remove();
}, 300);
}
// 初始化加载逻辑
updateLoadingText('正在加载,请稍后');
// 配置对象 - 根据是否使用本地资源调整
const loaderConfig = {
hostElement: document.getElementById('flutter-host')
''';
loaderLogic+='''
};
try {
_flutter.loader.load({
config: loaderConfig,
onEntrypointLoaded: async function(engineInitializer) {
try {
console.log('[LOADER] Flutter entrypoint loaded');
updateLoadingText('初始化Flutter引擎...');
// 初始化引擎
const appRunner = await engineInitializer.initializeEngine();
console.log('[LOADER] Engine initialized');
updateLoadingText('即将呈现...');
// 运行应用
await appRunner.runApp();
console.log('[LOADER] App running');
hideLoadingScreen();
} catch (error) {
console.error('[LOADER] Error during initialization:', error);
//updateLoadingText('Initialization error: ' + error.message);
updateLoadingText('很快就好啦');
hideLoadingScreen();
}
}
});
} catch (e) {
console.error('[LOADER] Error in loader setup:', e);
updateLoadingText('Fatal loader error: ' + e.message);
hideLoadingScreen();
}
// 监听Flutter的首帧渲染事件
window.addEventListener('flutter-first-frame', function() {
console.log('[LOADER] Flutter first frame rendered - hiding loading screen');
hideLoadingScreen();
});
''';
// 确保只添加一次加载逻辑
if(!bootstrapContent.contains('// ===== Loading Screen Logic')) {
bootstrapContent+=loaderLogic;
}
// 4. 保存修改后的文件
indexFile.writeAsStringSync(indexContent);
bootstrapFile.writeAsStringSync(bootstrapContent);
// 5. 如果是本地资源模式,添加资源预加载
if(localAssets) {
_addResourcePreloading(buildDir,indexContent);
}
print('Successfully injected loading screen!');
}
void_addResourcePreloading(StringbuildDir,StringindexContent) {
// 查找所有需要预加载的资源
finalassetDir=Directory('$buildDir/assets');
finalpreloadTags=StringBuffer();
if(assetDir.existsSync()) {
assetDir.listSync(recursive:true).whereType<File>().forEach((file) {
finalpath=file.path.replaceFirst('$buildDir/','');
finalextension=path.split('.').last;
// 只预加载关键资源
if(extension=='wasm'||extension=='js'||path.contains('canvaskit')) {
finalasType=extension=='wasm'?'fetch':'script';
preloadTags.writeln('<link rel="preload" href="$path" as="$asType" crossorigin="anonymous">');
}
});
}
if(preloadTags.isNotEmpty) {
finalupdatedContent=indexContent.replaceFirstMapped(
RegExp(r'</head>',caseSensitive:false),
(match)=>'$preloadTags</head>'
);
File('$buildDir/index.html').writeAsStringSync(updatedContent);
print('Added ${preloadTags.toString().split('\n').length} resource preload tags');
}
}
Dart
之后新建一个build.ps1文件用于执行脚本就可以啦,当然GitHub actions是Linux环境,所以使用.sh脚本即可。
param(
[switch]$NoCdn,
[switch]$DebugMode
)
$flutterArgs="build","web"
$injectArgs=@()
$flutterArgs+="--no-web-resources-cdn"
if($NoCdn){
$flutterArgs+="--no-web-resources-cdn"
$injectArgs+="--local-assets"
}
if($DebugMode){
$flutterArgs+="--debug"
}
Write-Host"Running Flutter build..."
Write-Host$flutterArgs
flutter$flutterArgs
if($LASTEXITCODE-ne0){
Write-Host"Flutter build failed"-ForegroundColor Red
exit1
}
Write-Host"Injecting loading screen..."
dart run tools\inject_loading_screen.dart$injectArgs
if($LASTEXITCODE-ne0){
Write-Host"Injection script failed"-ForegroundColor Red
exit1
}
Write-Host"Build and injection completed successfully!"-ForegroundColor Green
PowerShell
这样就可以实现有加载进度条,提高用户体验。
感谢你看到这里啦~ 另外个人主页也开源啦,大家可以下载修改自由使用~
https://github.com/cc2562/ricehome3
关于Flutter for web的字体问题和CDN问题,大家可以看米饭之前的文章~