Skip to content

平台特定注意事项 ​

跨平台开发中各平台的特殊处理和已知问题。


Android ​

构建准备 ​

bash
sdkmanager --licenses   # 首次构建前接受许可证

需要 Android SDK、NDK、JDK 17+。

运行时注意 ​

  • Android 13+(API 33):需运行时申请通知权限 POST_NOTIFICATIONS,否则无法显示播放控制通知
  • HyperOS3:需设置 androidStopForegroundOnPause: false,否则暂停时前台服务被回收,后台播放中断
  • APK 分架构构建:flutter build apk --split-per-abi 生成 arm64-v8a、armeabi-v7a、x86_64 三个 APK

本机歌曲缓存目录 ​

在「设置 → 缓存管理 → 本机歌曲缓存」中选择本机或 SD 卡文件夹(推荐 Music/Songloft),新缓存将保存到该目录,文件管理器无需 Root 即可访问。应用会请求系统媒体扫描,让其他播放器发现已完成的文件;是否显示还取决于播放器的扫描范围。仅支持本地存储提供者,不支持云盘目录。

  • 默认继续使用应用内部目录。选择目录和恢复默认只影响新增缓存;已有文件仍按索引中的原位置播放,可使用「迁移已有缓存到当前目录」单独迁移。
  • 迁移会停止缓存歌曲的播放,并临时占用额外磁盘空间。逐首复制、保存索引成功后才删除原文件;取消或失败时保留已完成的迁移,其余原文件不删除。若复制成功后原件删除失败,原位置可能留下副本。
  • 缓存索引与目录偏好留在应用内部,目录授权不参与服务器偏好同步。清空缓存只删除索引记录的歌曲,不清空所选目录里的其他文件。
  • SD 卡移除或授权失效时,缓存记录保留;重新插入并选择原文件夹授权后可继续使用。卸载应用后外部音频文件仍保留,但应用内部索引会丢失,重新安装不会自动收录这些文件。
  • Android 11+ 的系统选择器不能授权存储根目录、Download 根目录及 Android/data / Android/obb。请选择允许的子目录。
  • 新存储能力需要安装新版 APK;只有 Dart 热更新的旧 APK 会显示升级提示。播放沿用 media_kit 的 content:// → 文件描述符支持。

故障排查 ​

bash
# Gradle 缓存损坏
flutter clean && rm -rf android/.gradle
flutter pub get

iOS ​

构建与分发 ​

  • 使用 flutter build ios --no-codesign 构建未签名 IPA
  • 通过 AltStore / Sideloadly 侧载安装(不需要开发者账号)
  • 仅在 macOS 上可构建

CocoaPods 问题 ​

bash
cd ios
pod deintegrate
pod install
cd ..
flutter clean && flutter pub get

macOS ​

窗口管理 ​

使用 window_manager + tray_manager 实现:

  • 窗口大小和位置记忆
  • 系统托盘图标 + 菜单
  • 单实例运行

签名与存储 ​

应用未签名时无法使用 Keychain(FlutterSecureStorage 依赖它),已统一改用 SharedPreferences 存储 Token,不再受签名影响。


Windows ​

音频后端 ​

Windows 端通过 just_audio_media_kit 使用 libmpv 作为音频后端:

  • 打包的是 LGPL 构建(不含 GPL 编码器如 libx264/libx265)
  • 依赖 media_kit_libs_windows_video(含视频输出,取代旧的 audio 变体,用于应用内视频画面 songloft-org/songloft#76)

构建要求 ​

  • Visual Studio 2022(带 C++ 桌面开发工作负载)
  • Windows 10 SDK

MSIX 打包 ​

bash
flutter pub run msix:create

Token 存储 ​

Windows 平台的 SharedPreferences 读取偶尔不稳定,AuthInterceptor 优先使用内存缓存的 cachedAccessToken,仅在缓存为空时才读存储。


Linux ​

系统依赖 ​

bash
# Ubuntu/Debian
sudo apt-get install clang cmake ninja-build pkg-config libgtk-3-dev

音频后端 ​

Linux 端同样通过 just_audio_media_kit 使用 libmpv,但采用动态链接系统的 libmpv(非静态打包),用户系统需安装 mpv/libmpv。

GTK 启动时会按系统环境设置进程 locale,而 libmpv 要求 LC_NUMERIC=C。Linux runner 在 GTK 启动后、Flutter 引擎创建前仅重置数值 locale,保留语言等其他类别,避免 mpv_create() 返回 NULL 导致播放崩溃(#49)。

客户端使用 media_kit 本地补丁,在两条初始化路径上检查创建及初始化失败,并将异常传回播放调用方。失败会记录诊断日志并释放实例,不会无限等待初始化;再次播放可以重新创建播放器。此修复需要更新客户端整包,Linux runner 不能通过 Dart 补丁更新。

分发格式 ​

构建脚本支持 tar.gz、deb、rpm、AppImage 四种格式。


Web ​

构建参数 ​

  • 必须加 --no-web-resources-cdn,否则 Flutter 会从 Google CDN 加载字体/canvaskit,国内环境访问缓慢或失败
  • standalone 模式:flutter build web --no-web-resources-cdn
  • embedded 模式:flutter build web --no-web-resources-cdn --dart-define=DEPLOY_MODE=embedded
  • CanvasKit 变体:web/index.html 用 canvasKitVariant: "auto",由引擎按浏览器选变体——Chromium 内核用体积更小的 chromium 变体,Firefox/Safari 用 full。二者都走 WebGL,与 CPU/GPU 渲染模式无关(canvasKitForceCpuOnly 是另一回事,早已移除)。因此 embedded 自托管构建必须保留 canvaskit/chromium 子目录(scripts/build-frontend.sh 只删 skwasm/wimp/.symbols,不删 chromium),否则 Chrome 离线会 404 白屏

部署模式差异 ​

standaloneembedded
API 地址用户在登录页手动配置自动使用 Uri.base(同域)
API 地址 UI显示隐藏(编译时 tree-shaking 移除)
子路径部署需手动配置Uri.base.path 自动检测

条件导入 ​

Web 平台不支持 dart:io,涉及原生功能(WebView、文件系统)的代码使用 stub 文件模式:

plugin_webview_page.dart       → export 入口
plugin_webview_page_stub.dart  → Web 平台(占位 UI)
plugin_webview_page_native.dart → 原生平台(真实实现)

项目中的 stub 文件对:

  • plugin_webview_page — JS 插件 WebView
  • plugin_tab_page — 插件 Tab 页
  • web_cache_clearer — Web 缓存清理

存储降级 ​

  • PlaybackStateStorage:Web 平台降级为 SharedPreferences(localStorage),原生平台用文件
  • LyricCacheService:Web 平台降级为纯内存缓存,原生平台用文件系统

封面 / 网络图片必须缩略解码(CanvasKit 大纹理踩坑) ​

铁律:web 上所有网络封面图必须走 CoverImage 或 NetworkCoverImage,禁止直接裸用 CachedNetworkImage / Image.network。

  • 现象:列表/网格封面在切 tab、切筛选、来回导航后偶发变纯黑,继续操作又变成默认占位图标;而常驻的播放器大图从不出问题。
  • 根因:CanvasKit 默认 web 渲染方式 ImageRenderMethodForWeb.HtmlImage 把封面按原图全分辨率上传为 GPU 纹理(实测 ~1.5MB/张),且此路径下 memCacheWidth 不生效。列表大量大封面纹理累积挤爆移动端浏览器 GPU 显存 → CanvasKit 的 WebGL context 被丢弃 → 已上传纹理失效(黑)、新解码 MakeLazyImageFromTextureSourceWithInfo 返回 null 抛 ImageCodecException: Failed to create image from Image.decode(占位图标)。常驻 widget(播放器大图)因 listener 永不释放、被钉在 imageCache 的 live 集合永不淘汰,故不受影响——这正是"播放器不丢、列表丢"的原因。
  • 修复:封面组件强制 imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet + memCacheWidth(按显示尺寸 × DPR)缩略解码,纹理从 ~1.5MB 降到数十~百 KB,大幅降低 GPU 显存压力;且 HttpGet 字节可在 context 恢复后重解码,比 <img> 惰性纹理更抗 context 丢失。已封装在:
    • shared/widgets/cover_image.dart(CoverImage,定尺寸方形封面)
    • shared/widgets/network_cover_image.dart(NetworkCoverImage,填充式 / 自定义布局封面)
  • 最易踩的坑:新增封面渲染点时必须用上述两个组件之一;图省事直接写 CachedNetworkImage(imageUrl: ...),web 上就会重新引入变黑。历史上正因专辑 / 歌手 / 歌单卡片漏用封装、直接裸用 CachedNetworkImage,导致这些列表封面单独变黑(歌曲列表因用了 CoverImage 反而正常),排查绕了很多弯。
  • 排查判据:ImageCodecException: Failed to create image from Image.decode = GPU 纹理创建失败(context 丢失 / 显存耗尽),不是网络或字节问题;纯黑 = 已解码 ui.Image 的纹理失效(errorWidget 捕获不到,因失败在 GPU 绘制层);占位图标 = 走到了 errorWidget(解码 / 加载真失败)。
  • 超大歌单的后续(songloft-org/songloft#309):歌单/队列上千首时纹理压力更大,还叠加两个后续问题,当前缓解栈为:
    1. 崩溃:context 丢失时 CkSurface 访问未初始化 late 字段崩溃(flutter/flutter#184683)→ web 构建切到含官方修复 #185116 的 beta 3.47(见 .github/workflows/build-and-release.yml)。
    2. 回滚封面变空白:崩溃修好后,离屏但仍驻留 imageCache 的封面其 GPU 纹理随旧 context 一起失效,回滚时同步命中缓存拿到死纹理画成空白,且不再重新解码。因 imageCache 存的是解码后的 ui.Image(GPU 纹理句柄)而非编码字节,"命中缓存"反成陷阱。→ core/utils/webgl_context_recovery.dart 的 installWebGLContextRecovery(main.dart 内 web 挂载)在 webglcontextlost/restored 时清空 imageCache,逼回滚重新解码(字节仍在 cached_network_image 本地缓存,不重新联网)。
    3. 纹理压力:core/utils/web_image_tuning.dart 在 web 端收紧大列表 cacheExtent(250→100)并按显示尺寸解码小封面(队列/侧栏 36–48px),降低同时驻留纹理数与体积。
  • 不要走的弯路(均已验证无效或更差):调大 imageCache(根本没触发 LRU 淘汰)、imageCache.evict / 换 key 重建(治标且加剧重解码)、canvasKitForceCpuOnly(全局软件渲染极卡且不解决)、canvasKitMaximumSurfaces=1(Chrome 走单 OffscreenCanvas,无效)。