- 桌面应用
- 跨平台
- 前端
【免费下载链接】readest
Readest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.
导读
本文基于 Readest 仓库中 android-bg-tts-media-session-fix.md 这份修复记录,完整还原一次 Android 端"朗读(TTS)后台播放"回归问题的排查与修复全过程:从"后台startService()被系统拒绝"到"前台服务(FGS)从未启动"再到"Tauri 在 serde 反序列化层直接拒绝命令"三层根因的层层剥离,并附带锁屏媒体会话(MediaSession)时长显示与拖动 seek 功能的落地实现。读完你将掌握:Android 8+ 后台服务启动限制(BSSR)下如何通过进程内调用更新已运行的服务、前台服务通知权限(POST_NOTIFICATIONS)的正确请求时机、以及 Tauri 移动端命令参数校验失败时如何第一时间从 WebView 控制台定位问题。
一、背景:一次后台 TTS 回归问题的现场
Readest 是跨平台电子书阅读器,其"朗读"(Read Aloud)功能在移动端通过tauri-plugin-native-tts插件驱动 Android 原生TextToSpeech/ WebAudio(Edge 引擎)播放,并用一个前台服务承载锁屏媒体控制。在两次上游改动合入之后:
- #4941(媒体会话与播放会话解耦)
- #4931(Edge WebAudio 引擎)
Android 端出现了明确回归:应用退到后台后,锁屏媒体控制失效,朗读音频直接中断。logcat 中留下了关键报错:
Not allowed to start service Intent { act=UPDATE_PLAYBACK_STATE ... MediaPlaybackService }: app is in background修复工作落在分支fix/android-bg-tts-media-session,共 5 个提交(PR readest/readest#4994 已合并,2026-07-07):
| 提交 | 主题 |
|---|---|
15817fc4b | 进程内 IPC(in-process IPC) |
04e4b4fe6 | 媒体会话时长显示 + seek |
67c22b72b | 前台服务加固 + 诊断日志 |
27e224bcc | 移除keepAppInForeground(真正的修复) |
a8643ec12 | Edge 边缘淡入淡出点击修复 |
下文按"表象根因 → 更深的根因 → 真正的根因"三层展开,这与实际排障顺序一致。
二、第一层根因:Android 8+ 禁止后台startService()更新服务
2.1 问题机制
Android 8.0(API 26)引入后台服务启动限制(Background Service Start Restrictions,BSSR):应用处于后台时,Context.startService()会被系统拒绝并抛出IllegalStateException,日志即为开头的 "app is in background"。除非应用存在已激活的前台服务可以豁免该限制。
修复前的NativeTTSPlugin是这样推送媒体会话更新的:
// 旧实现(问题代码):每次朗读 mark 都通过 startService 通知服务 activity.startService(Intent(activity, MediaPlaybackService::class.java).apply { action = "UPDATE_PLAYBACK_STATE" // ... })朗读是逐句推进的,每个 mark 都要刷新一次锁屏上的播放状态和元数据。应用一旦退到后台,每一次逐句更新都会抛异常,结果:
- FGS 通知停止刷新,锁屏媒体控制变成过期状态;
- 音频路由丢失,后台播放随之停止。
2.2 修复模式:永远不要startService()与运行中的服务通信
MediaPlaybackService本来就有一套"进程内直接调用运行实例"的模式:静态@Volatile instance引用 +requestDeactivation()通过主线程Handler投递调用。修复在此基础上补齐了两个伴生入口(源码见 MediaPlaybackService.kt):
companion object { @Volatile private var instance: MediaPlaybackService? = null // 元数据更新:刷新静态变量后,通过主线程 Handler 投递给运行实例 fun pushMetadata(title: String, artist: String, artwork: Bitmap?) { currentTitle = title currentArtist = artist if (artwork != null) currentArtwork = artwork val service = instance ?: return Handler(Looper.getMainLooper()).post { service.applyMetadata() } } // 播放状态更新:position/duration 为 null 时保留最后已知值 fun pushPlaybackState(playing: Boolean, position: Long?, duration: Long?) { if (position != null) currentPositionMs = position if (duration != null) currentDurationMs = duration val service = instance ?: return Handler(Looper.getMainLooper()).post { service.applyPlaybackState(playing) } } }配套改动:
- 新增私有实例方法
applyMetadata()/applyPlaybackState(),在主线程把静态值灌入当前会话与通知; - 删除了已死的
UPDATE_METADATA/UPDATE_PLAYBACK_STATEintent 分支以及不再使用的serviceScope/kotlinx.coroutines.*导入。
调用侧(NativeTTSPlugin.kt)也同步改为进程内调用:
@Command fun update_media_session_metadata(invoke: Invoke) { // In-process update on the running service; never startService() // — that throws "app is in background" once backgrounded. MediaPlaybackService.pushMetadata(title, artist, artworkBitmap) invoke.resolve() } @Command fun update_media_session_state(invoke: Invoke) { // position and duration are null on a bare play/pause flip; // the service keeps the last known values so the scrubber does not reset. MediaPlaybackService.pushPlaybackState(isPlaying, args.position?.toLong(), args.duration?.toLong()) invoke.resolve() }这里有一个重要的 Android 语义区分:startForeground()用于"更新"一个已经处于前台的服务,在后台是允许的;而Context.startForegroundService()用于"启动"一个新服务,从后台调用才是受限的。前者恰恰是该修复可行性的前提——服务已经在set_media_session_active时被startForegroundService拉起了。
三、第二层根因:POST_NOTIFICATIONS从未被请求
3.1 问题机制
Android 13(API 33)引入POST_NOTIFICATIONS运行时权限。FGS 的媒体通知(也就是锁屏媒体控制的载体)若没有该权限会被静默抑制。而 #4941 在TTSMediaBridge.bind()的setActive({active: true})调用中丢弃了keepAppInForeground与通知标题等字段:
mediaSession.ts中requestPostNotificationPermission()原本由keepAppInForeground门控;- 而
keepAppInForeground默认值为false(对应constants.ts中的alwaysInForeground设置)。
于是POST_NOTIFICATIONS从未被请求,Android 13+ 上 FGS 媒体通知(= 锁屏控制)被系统静默抑制。
3.2 修复:每次激活都请求权限(决定一次后即为 no-op)
在 mediaSession.ts 的setActive()中,权限请求改为每次激活都执行(系统决定一次后即为 no-op),不再受开关门控:
async setActive(sessionState: MediaSessionState) { if (sessionState.active) { // The foreground-service media notification IS the lock-screen control; // on Android 13+ it is silently suppressed unless POST_NOTIFICATIONS is // granted. Request it on every activation (no-op once decided). try { await this.requestPostNotificationPermission(); } catch (error) { console.warn('POST_NOTIFICATIONS request failed:', error); } try { await this.initializeListeners(); } catch (error) { console.warn('Media session listener init failed:', error); } } // ... }注意这里权限请求被放进了独立的 try/catch,目的就是"best-effort":它绝不能因为抛错或阻塞而中断随后必须执行的set_media_session_active(即 FGS 启动)。这一解耦随后被证明是必要的(见下文提交 3)。
四、第三层(真正)根因:serde 必需字段导致 Tauri 命令在参数层被拒
4.1 排障过程的弯路
设备实测(Xiaomi / MIUI,targetSdk 36)第一轮就发现了两个问题:
- 测试 APK 是旧的:logcat 仍显示
startService(act=UPDATE_METADATA/UPDATE_PLAYBACK_STATE),说明修复根本没编译进 APK(很可能从 main 树而非 worktree 构建的); - 更深的问题:
W/ActivityManager: Stopping service due to app idle: ...MediaPlaybackService—— 服务从未被提升为前台服务(FGS 不会被 idle-stop,且 readest 的 uid 从未出现在 FGS 类型日志中)。
MIUI 环境还额外敌对:uid 10186(SecurityCenter)反复把 readest 的post_notificationappop 置为ignore,甚至直接Force stopping service。另外音频实际由 WebView(org.chromium.content.browser.AudioFocusDelegate持有焦点)播放,而服务的 ExoPlayer 也请求AUDIOFOCUS_GAIN,存在疑似焦点抢占冲突(未证实)。
提交 3(67c22b72b)做了前台服务加固 + 诊断日志,这为下一轮排查提供了探针:
showNotification()改用ServiceCompat.startForeground(this, id, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK),显式声明 FGS 类型(targetSdk 34+ 必需),并用 try/catch + Log 包裹(见 MediaPlaybackService.kt);mediaSession.ts的setActive中POST_NOTIFICATIONS请求独立 try/catch 解耦;- 增加 trace 日志:插件侧
set_media_session_active: startForegroundService、服务侧activateSession (wasActive=)、startForeground ok/failed。
4.2 真相:命令从未执行
第三轮排查中,从 WebView 控制台(adb logcat的 chromium tag 下的[INFO:CONSOLE])抓到了决定性的错误:
Failed to set media session active state: invalid args payload for command set_media_session_active: missing field keepAppInForeground根因链条如下:
- Rust 侧
SetMediaSessionActiveRequest(models.rs)中,keep_app_in_foreground: bool是serde 必需的字段(其余字段全部是Option<T>); - #4941 的
ttsMediaBridge.bind()发送的是setActive({active: true}),没有携带keepAppInForeground; - 于是 Tauri 在serde 反序列化层就拒绝了这次 invoke,
set_media_session_active命令根本没执行; - 结果:FGS 从未启动 → 没有通知 → Android 15 的
AS.AudioService: AudioHardening background playback would be muted直接静音后台播放。
此前所有修复(进程内 IPC、FGS 加固、POST_NOTIFICATIONS 解耦)都在这条命令的下游,命令不执行,它们自然全部无效。诊断陷阱是:logcat 中找不到原生 tag(MediaPlaybackService/NativeTTSPlugin),因为服务从未被触及;答案只存在于 WebView JS 控制台(grep logcat 的CONSOLE)。
4.3 修复:彻底移除keepAppInForeground
提交 4(27e224bcc)按用户要求("default true")将keepAppInForeground整体删除——它在 Rust / Kotlin / iOS / TS 各层载荷中已无任何读取方,FGS 总是启动,POST_NOTIFICATIONS变为无条件请求。跟进提交0b8843012进一步清理:
- 移除已死的
alwaysInForeground设置项及其 Android 设置菜单中的 "Background Read Aloud" 开关(涉及settings.ts/constants.ts/SettingsMenu.tsx及测试); - 通过
pnpm i18n:extract清理 33 个语言包中的对应 i18n key。
当前 models.rs 中SetMediaSessionActiveRequest已无该字段,仅保留active: bool(必填)与owns_audio_focus、notification_title、notification_text、foreground_service_title、foreground_service_text、book_hash、book_title、book_author(全部可选):
#[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct SetMediaSessionActiveRequest { pub active: bool, // Android: whether the media service should hold the app's audio focus ... pub owns_audio_focus: Option<bool>, pub notification_title: Option<String>, pub notification_text: Option<String>, pub foreground_service_title: Option<String>, pub foreground_service_text: Option<String>, // Identity of the book being read, persisted so the Android Auto browse // tree can offer a "Resume last book" entry after the process is cold. pub book_hash: Option<String>, pub book_title: Option<String>, pub book_author: Option<String>, }经验教训:Tauri 移动端命令失败时,优先抓取 WebView 控制台(logcat 的CONSOLEtag)——serde 参数拒绝只会在那里显现,原生日志中看不到任何痕迹。
五、功能增强:媒体会话的章节时长显示与拖动 seek
修复回归之外,本分支还完成了一个用户需求:在媒体会话中展示"估算的章节时长"并支持从锁屏/车载拖动 seek。有趣的是,JS 侧早已具备能力,原生侧从未使用:
ttsMediaBridge.#updatePositionState()每个 mark 都会发送{playing, position, duration}(毫秒)——见 ttsMediaBridge.ts;mediaSession.ts早已监听media-session-seek事件 →handlers['seekto']→controller.seekToTime(pos / 1000)——见 mediaSession.ts。
原生侧(MediaPlaybackService.kt)补齐了三处:
- 时长元数据:
buildMediaMetadata()中写入MediaMetadataCompat.METADATA_KEY_DURATION——Android 从 METADATA 读取进度条总长,从 PlaybackState 读取滑块位置; - seek 动作:
setActions中加入PlaybackStateCompat.ACTION_SEEK_TO; - seek 回调:
SessionCallback.onSeekTo(pos)触发pluginEventTrigger("media-session-seek", {position})并把滑块乐观移动到目标位置(见 MediaPlaybackService.kt),让锁屏在 seek 落地前先有响应:
override fun onSeekTo(pos: Long) { currentPositionMs = pos pluginEventTrigger?.invoke("media-session-seek", JSObject().apply { put("position", pos) }) val state = if (player.isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED mediaSession?.setPlaybackState(stateBuilder.setState(state, pos, 1f).build()) }两个值得注意的实现细节:
- 暂停时进度条不回零:纯 play/pause 更新省略了 position/duration,因此
pushPlaybackState(playing, position: Long?, duration: Long?)在参数为 null 时保留最后一次已知值,否则一暂停滑块就跳回 0; - 章节时间线仅限 Edge/WebAudio 引擎:
TTSController注释明确 "position/duration/seek (Edge client only)",getPlaybackInfo()对原生TextToSpeech返回 null——原生 TTS 下 duration 保持 0,锁屏不出现进度条,这是符合预期的行为(原生引擎没有可用时间线)。
JS 侧 seek 事件还有一个值得注意的细节:addPluginListener直接交付 payload,读取.payload.position反而会抛错导致锁屏/Android Auto 的 seek 永远到不了seekToTime——修复后直接读payload.position(mediaSession.ts)。
六、配套机制:静音保活播放器与音频焦点仲裁
理解该服务的设计,还需看清两个配套机制(均已在 MediaPlaybackService.kt 中实现):
静音保活播放器。真实 TTS 音频渲染在 WebView(或 TextToSpeech)中,服务内仅用一个循环播放 10 秒silence.mp3的 ExoPlayer 充当"静音保活轨道"——持有音频路由、驱动会话的 playing/paused 状态(activateSession()中player.repeatMode = Player.REPEAT_MODE_ONE并playWhenReady = true)。因此updatePlaybackState()上报的位置必须来自currentPositionMs(WebView 的真实进度),绝不能读取本地保活播放器的currentPosition——它会饱和在 ~10 秒处,把车载/锁屏进度条冻住。
音频焦点仲裁。服务用AudioFocusRequest(USAGE_MEDIA+CONTENT_TYPE_SPEECH+setWillPauseWhenDucked(true))加入"有声书契约":导航提示等短暂焦点丢失会暂停朗读、焦点恢复后继续,永久丢失则保持暂停且不自动恢复。但ownsAudioFocus默认为 true 且存在例外:音频由 WebView<audio>元素播放时(如听书),Chromium 会以同一 uid 请求AUDIOFOCUS_GAIN,抢在服务请求之前,服务约 15ms 后收到AUDIOFOCUS_LOSS并把media-session-pause转发回 WebView,导致听书开始不到一秒就被暂停。此时应用需在setActive时传ownsAudioFocus: false,让服务让出焦点仲裁(见 mediaSession.ts 的MediaSessionState.ownsAudioFocus注释)。
七、排障方法论:从本案例沉淀的三条经验
- 先验证测试物料本身:第一轮设备实测失败是因为 APK 是旧的(logcat 中仍出现已被删除的
startService调用)。设备复测前务必确认构建来自修复分支,必要时先adb uninstall再装。 - MIUI/国产 ROM 需要额外的后台豁免配置:Autostart 开启、电池策略"无限制"、最近任务中锁定。
SecurityCenter反复把post_notificationappop 置为 ignore 属于厂商侧干扰,排查时要把这类噪音与自身代码问题区分开。 - Tauri 移动端命令失败先看 WebView 控制台:原生 tag 静默 + JS 控制台报错(logcat
CONSOLEtag),是 serde 层参数拒绝的典型特征。如果只盯原生日志,set_media_session_active这类"从未执行"的命令会表现得像是"执行了但没生效"。
八、验证与收尾
修复完成后执行了完整验证链:
pnpm test:7022 个用例全部通过;pnpm lint:干净无告警;cargo check / fmt / clippy -p tauri-plugin-native-tts:全部通过。
需要说明的限制:合并时Kotlin 代码未经编译与真机验证——worktree 的src-tauri/gen/android缺少tauri.settings.gradle,插件依赖的app.tauri.plugin.*无法独立解析;需要pnpm tauri android在 Android 13+/14 真机上做最终确认(logcat 流程:前台 → 后台 → 锁屏)。该分支的验证记录与后续相关话题(会话解耦、Edge WebAudio 引擎、iOS 原生 TTS)属于同一系列迭代,本文聚焦 Android 侧链路。
九、源码地图
读者可沿以下路径深入本次修复涉及的完整链路:
- 记忆文档:android-bg-tts-media-session-fix.md(本文章的事实骨架)
- Android 服务实现:MediaPlaybackService.kt(进程内 push 模式、FGS 加固、seek 回调、音频焦点仲裁)
- 插件命令层:NativeTTSPlugin.kt(
set_media_session_active/update_media_session_state/update_media_session_metadata) - Rust 参数模型:models.rs(serde 字段定义,
keepAppInForeground已移除) - Rust 命令分发:mobile.rs(
run_mobile_plugin桥接) - TS 媒体会话封装:mediaSession.ts(权限请求、事件监听、平台分发)
- TS 桥接层:ttsMediaBridge.ts(
bind/unbind、位置状态推送、乐观跳过、保活音频) - 测试:mediaSession.test.ts、tts-media-bridge.test.ts
结语:这次修复最有价值的产出不是某个补丁,而是一套可复用的排查范式——后台场景下先怀疑"命令是否真的执行了"(WebView 控制台),再怀疑"执行的路径是否合法"(startService 限制),最后才是功能层面的表现。三层根因对应三种不同的调试面,缺一不可。- 桌面应用
- 跨平台
- 前端
【免费下载链接】readest
Readest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.
相关推荐
解决Redisson XCLAIM参数校验陷阱:从报错到修复的完整指南
解决Redisson XCLAIM参数校验陷阱:从报错到修复的完整指南 你是否遇到过Redisson操作Redis Stream时的参数校验异常?在分布式系统中
后端缓存数据库客户端分布式Cloudflare Workers 中的 WebSocket 实战:Readest 的 fetch 升级模式与 Blob 二进制帧陷阱
Cloudflare Workers 中的 WebSocket 实战:Readest 的 fetch 升级模式与 Blob 二进制帧陷阱 导读 在 Cloudf
桌面应用跨平台前端CF-Workers-Raw终极指南:3分钟学会安全访问GitHub私有仓库
CF Workers Raw终极指南:3分钟学会安全访问GitHub私有仓库 想要安全访问GitHub私有仓库中的原始文件又不想暴露你的GitHub令牌?CF
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考