- 移动开发
- 音视频
- 插件系统
【免费下载链接】BiliPai
原生、纯净、可扩展 —— 重新定义你的 B 站体验
导读
本文围绕 BiliPai 项目中捆绑的miuix-navigation模块展开,系统讲解该项目如何将上游 compose-miuix-ui/miuix 的miuix-nav-android源码(0.9.4-5c91d5e5-SNAPSHOT)纳入自身仓库,并在其基础上实现按 session 冻结的预测式返回释放策略、视频卡片深度集成以及深链与状态接入。读完本文,你将掌握该模块的版本管理思路、NavBackCompletionPolicy补丁的完整实现原理、连续深度浮点导航的渲染机制,以及 BiliPai 在视频卡片返回场景下的具体接入方式。
模块定位:为什么在仓库内捆绑一份上游导航源码
miuix-navigation是 BiliPai 仓库内的一个独立 Gradle 模块,其定位在 miuix-navigation/README.md 中写得很清楚:它不是对上游 artifact 的简单依赖,而是将上游发布的精确源码直接捆绑在仓库内,并附带一套本地补丁。
- 上游来源:compose-miuix-ui/miuix 的
miuix与miuix-nav-android,版本固定为0.9.4-5c91d5e5-SNAPSHOT,按上游 Apache-2.0 许可证捆绑(模块根目录 miuix-navigation/LICENSE)。 - 本地化适配:上游代码中的 Android
expect/actual声明被解析为普通 Android 函数,使该模块在 Android 目标上可直接编译运行。 - 构建意义:应用(
app模块)直接依赖project(":miuix-navigation")而不是发布到 Maven 的miuix-navartifact,从而保证补丁在 CI 与本地构建中可复现。README 同时提醒:升级这些源码时,其余 Miuix 模块应保持匹配的版本。
这种“源码内联 + 补丁可复现”的做法,核心目的是让NavDisplay、NavBackStack等导航运行时的行为对 BiliPai 完全可控,而不是被上游发布节奏牵制。
版本固定与升级评审:九个提交背后的决策
模块内还附有一份中文升级评审文档 miuix-navigation/UPGRADE_REVIEW.md(评审日期 2026-10-02),记录了版本决策的完整论证过程,要点如下:
- 版本事实:gradle/libs.versions.toml 将版本固定为
0.9.4-5c91d5e5-SNAPSHOT,该版本处于上游正式版v0.9.4之后九个提交,其中包含“导航切换时清除焦点”的修复。 - 不降级:评审时核对了
v0.9.4到5c91d5e5的提交差异,确认5c91d5e5之后的上游 main 分支提交仅涉及示例与 README、未修改miuix-nav;因此降回0.9.4只会回退已合入的修复,故保持固定版本与本地导航源码。 - 不引入第二套运行时:
miuix-nav不依赖androidx.navigation3,尽管两者的 NavKey、NavDisplay 与 back stack API 表面相似,但 Google 的 Scene、Result 与 Deep Link API 并不会自动成为 Miuix Nav 的功能,因此没有必要“仅为了 Deep Link 就整体迁移到 Navigation 3”。 - 本次收敛:移除没有生产代码引用的
lifecycle-viewmodel-navigation3依赖、移除未使用的远程miuix-navigationcatalog 别名,并更新了两个旧结构测试——从“要求 Nav3 alpha07 与 entry decorators”改为“检查本地 Miuix 接入及无旧 Nav3 依赖”。 - 保留历史命名:应用的
navigation3包名属于历史遗留命名,并非 AndroidX 运行时依赖,刻意保留以避免无行为收益的大范围重命名。
评审文档还明确划分了验证边界:评审仅做了源码引用、版本提交比较与 diff 检查,未执行编译、Gradle 测试或安装,因此“全套测试通过”的说法在文档中不被主张——这一严谨态度值得在阅读时留意。
本地补丁的核心:按 session 冻结的返回完成策略
README 将本地修改概括为三件事:可选的按宿主(per-host)预测式返回释放策略、按 session 冻结(frozen for each session)、以及峰值与应用(锚定)进度跟踪。对应到源码,就是NavBackCompletionPolicy这一接口。
接口定义
在 NavBackCompletionPolicy.kt 中:
interface NavBackCompletionPolicy { fun shouldCommit(progress: Float, peakProgress: Float, velocity: Float): Boolean /** Opt-in only: a floating card can land despite a platform cancellation on release. */ fun shouldCommitOnCancel(progress: Float, peakProgress: Float): Boolean = false }shouldCommit:在手势释放、平台给出完成(commit)信号时决定是否真正出栈。默认路由保留上游的“平台完成 + 速度守卫”行为;只有启用该策略的宿主才用“已应用的姿态 + 本 session 峰值”来决策。shouldCommitOnCancel:默认返回false;仅在 opt-in 场景下,允许“悬浮卡片”在平台取消(cancel)信号下仍然落位完成返回。
注释明确说明:离散返回(discrete back)不受该策略影响,总是立即完成——策略只作用于预测式返回的终态事件。
BiliPai 的视频卡片实现
策略接口的上游语义由应用侧实现落地,见 VideoCardBackCompletionPolicy.kt:
internal object VideoCardBackCompletionPolicy : NavBackCompletionPolicy { private const val FLOATING_CARD_PROGRESS = 0.65f private const val RESTORE_DETAIL_PROGRESS = 0.35f override fun shouldCommit(progress: Float, peakProgress: Float, velocity: Float): Boolean { if (!progress.isFinite() || !peakProgress.isFinite() || !velocity.isFinite()) return false if (peakProgress >= FLOATING_CARD_PROGRESS) return progress > RESTORE_DETAIL_PROGRESS return velocity > -1f } override fun shouldCommitOnCancel(progress: Float, peakProgress: Float): Boolean = progress.isFinite() && peakProgress.isFinite() && peakProgress >= FLOATING_CARD_PROGRESS && progress > RESTORE_DETAIL_PROGRESS }这套阈值把“悬浮卡片模式”做成一个独立的、按 session 判定的状态机:
- 进入悬浮模式:手势峰值
peakProgress >= 0.65,即卡片已被充分缩小、脱离源位置。 - 离开悬浮模式:只有“向详情页显式展开”(
progress <= 0.35)才取消返回——反向速度或距源距离都不能离开悬浮模式。这就是 README 所说“explicit expansion back toward detail cancels”的量化表达。 - 悬浮模式内的完成判定:
progress > 0.35即落位返回,即使平台给出的是 cancel 信号(shouldCommitOnCancel)。 - 未进入悬浮模式:退回上游行为,用释放速度守卫(
velocity > -1f)判定。 - 数值安全:任何非有限值(NaN/Infinity)都拒绝完成,防止异常采样导致意外出栈。
该策略有配套的完整测试 VideoCardBackCompletionPolicyTest.kt,用 7 个用例锁定了边界行为:
- 悬浮卡片“从最远位置回转后落位”(
shouldCommit(0.50f, 0.95f, -3f)与shouldCommitOnCancel(0.50f, 0.95f)均为真); - 显式向详情展开取消(
0.30f, 0.95f均拒绝); - 悬浮模式前后行为分离:
shouldCommitOnCancel(0.65f, 0.65f)为真而(0.64f, 0.64f)为假,0.36f真、0.35f假,精确锁定 0.65/0.35 两条边界; - 新手势不继承上一 session 的悬浮模式(
(0.50f, 0.95f)真而(0.50f, 0.50f)假)——印证“按 session 冻结”; - 非法采样不触发 pop。
源码剖析:会话冻结与旧 session 隔离的实现
“冻结在每个 session”与“旧 session 的工作不能驱动或弹出新手势”这两条承诺,在 NavDisplay.kt 的渲染核心中有完整实现。
所有权令牌
PredictiveBackOwnership用单调递增的奇偶代际实现“谁持有共享驱动浮点”的所有权:
internal class PredictiveBackOwnership { var generation by mutableLongStateOf(0L) private set fun acquire(): Long { generation = if (generation and 1L == 0L) generation + 1L else generation + 2L return generation } fun release(lease: Long): Boolean { if (generation != lease) return false generation = lease + 1L return true } }偶数代表空闲;每次预测式返回开始推进到新的奇数代际,只有该 session 自己的终态回调能把租约推进到下一个偶数。这样即使某个旧 session 的延迟终态回调到达,也无法让一个正在进行的新 session 看起来处于空闲。
配套测试 PredictiveBackOwnershipTest.kt 的lateCancelCannotReleaseRegrabbedGesture用例精确验证了“迟到的取消不能释放已被重新抓取的会话”这一不变量。
会话数据结构与决策接线
PredictiveBackSession记录每次手势的ownershipLease、completionPolicy、peakProgress、progress、gesture与progressVelocity。NavDisplayLayout中的接线逻辑是:
- 手势开始(
onProgress):acquire()拿到新代际,创建 session,然后交给drivePredictiveBack逐事件snapToFinger;每次应用进度时更新session.progress并推进session.peakProgress = maxOf(peakProgress, progress)。 - 完成(
onCommit):sessionId == null的裸完成(桌面 ESC、程序化返回)属于离散返回,无条件onBack();否则用session.completionPolicy?.shouldCommit(...) ?: navBackCompletionDecision(velocity)决策,通过则commitPredictiveBack,否则cancelPredictiveBack。 - 取消(
onCancel):只有当shouldCommitOnCancel(...) == true时才提交,否则走取消恢复。 - 延迟回调防护:
commitPredictiveBack检查generation != session.ownershipLease时直接放弃;cancelPredictiveBack调用predictiveBackOwnership.release(session.ownershipLease)失败(返回 false)时也直接放弃——这正是“旧 session 不能驱动或弹出新手势”的代码级保证。
指跟随驱动:原子抓取锚点
NavPredictiveBackDriver.kt 实现了指跟随(finger-following)的一半契约:
if (!isActive()) return animatedTop.stop() // 先停掉在途 settle if (!isActive()) return val anchor = topIndex - animatedTop.value // 再从停住的值采样锚点顺序是刻意的:先stop()再采样锚点,避免在采样与首次 snap 之间弹簧再推进一帧;锚点就是“已经朝 pop 方向走过的进度”(topIndex - value,当 pop-settle 仍在把离场 entry 收上去时为负)。随后每个事件映射为animatedTop.snapToFinger(topIndex, event.progress, anchor)——严格线性、1:1 跟随手指,因此首个事件(progress ≈ 0)恰好落在采样值上,打断进行中的 push/pop 时零跳变。
drivePredictiveBack刻意不负责收敛(commit →topIndex - 1、cancel →topIndex),而是把终态决策留给调用方,用共享弹簧settleTo接住释放速度,保证 snap → spring 边界速度连续。
视频卡片深度集成:一次覆盖 entry 的形变
README 的 “Video-card depth integration” 一节描述了 BiliPai 视频卡片的返回动画规格,这是该模块在应用层最直观的产出:
- 视频转场将整个覆盖的导航 entry 一次性变换,满深度时居中缩放至 97%,并施加24dp 圆角裁剪;内层快照/浏览器 chrome 效果保持零缩放。
- 减少动态(reduced motion)时 entry 保持全尺寸。
- 脱离的源快照使用与被覆盖 entry 相同的缩放、形状与视觉进度;一旦源渲染器就绪即停止绘制。
- 视频卡片 session 期间,固定不透明背衬属于应用宿主、位于保留快照之下;
NavDisplay内部 backdrop 在该 session 中被指定为Color.Unspecified(见 BiliPaiNavDisplayHost.kt 中backdropColor = if (videoCardMorphOwnsCorners) Color.Unspecified else backdropColor),避免盖住快照;其他导航转场保留上游 backdrop。 - 全局壁纸保持根级持有,不再附加深度缩放;相关视频源保留真实播放器 entry——因为 Compose GraphicsLayer 录制无法包含 Surface 视频内容。
- 视频到视频的缩放间隙扩展黑色播放器/窗口画布,而不是暴露白色主题表面帧;原生卡片转场不再应用旧的“次主体 88% 让步缩放”或平移。
- 播放器固定系统栏 inset 在悬浮时被裁掉,在展开的最后 10% 内恢复,并与封面、视口高度共享延迟布局读取。
- 正在播放的胶囊只把宿主空间边界当作外层导航元数据:内部媒体帧从零开始、一次性补偿尺寸,冻结的位图/图层像素被拟合到已测帧,不再做第二次逆变换;封面、真实播放器与胶囊快照共享同一目标,即使状态栏仅图标或位图捕获不可用。
在BiliPaiNavDisplayHost中,这套规格由miuixVideoCardNavTransition(MiuixVideoCardNavTransition.kt)实现,并随VideoCardTransitionClock的状态机(OPENING / HELD / RETURNING / IDLE)逐帧驱动:只有会动的相位才打帧序号,同帧内多层 graphicsLayer 的重复读取走帧内记忆,避免状态机被同帧推进多次;HELD 稳态不持有帧回调,避免长时间播放空转。
应用接入:从返回栈到 NavDisplay
返回栈与路由层级
在 AppNavigation.kt 中,BiliPai 使用rememberNavBackStack<BiliPaiNavKey>(...)创建可序列化的完整路由层级返回栈,并显式断言NavDisplay拒绝空栈;底部 Tab 切换时用Snapshot.withMutableSnapshot在一次快照内完成clear()+addAll()替换,避免暴露空栈的中间帧,同时用navigation3SaveableStateHolder.removeState主动驱逐 session 级媒体详情 key 的保存状态,防止长时间观看后 Activity 保存 Bundle 触发TransactionTooLargeException。
rememberNavBackStack的实现(NavBackStack.kt)有两个值得注意的设计:
- 返回栈本质上是
SnapshotStateList<NavKey>的类型别名(typealias NavBackStack = SnapshotStateList<NavKey>),调用方可以直接操作列表(add/removeAt),也可经由NavController(NavController.kt)的push/pop/replace/popUntil。 - 持久化使用无反射的
Saver:navBackStackSaver利用调用点的 reified 类型拿到编译器生成的List<T>序列化器,@Serializable sealed路由层级因此是封闭多态序列化器,无需SerializersModule、无 JVM 反射。NavKey(NavKey.kt)是纯标签接口,文档示例要求用@Serializable sealed interface声明路由层级,否则首次组合即抛SerializationException。
NavDisplay 的 DSL 与效果
NavDisplay.kt 提供三个重载:直接传backStack、传NavController、以及内部低层entryProvider版本。核心 DSL 是NavEntryBuilder.entry<T>,支持按路由覆盖转场(transition)、覆盖滑出方向(swipeDismiss,传NavSwipeDirection.None可禁用该路由的手势)、以及自定义contentKey——文档特别强调 contentKey 必须在整条返回栈内唯一、并且必须是值派生的(用 data class/data object 或返回派生 key),否则会触发 reconcile 时的IllegalArgumentException或在进程死亡后悄悄重置rememberSaveable状态。
BiliPai 的接入位于BiliPaiNavDisplayHost:NavDisplay(backStack, onBack = performBack, transition = globalTransition, effects = effects, backCompletionPolicy = if (cardMorphAvailable) VideoCardBackCompletionPolicy else null)。其中:
performBack对VideoDetail出栈做预处理(准备共享返回、启动beginReturning),并处理相关视频返回时的父 session 恢复。effects按“视频卡片形变是否拥有四角”决定NavCornerClipMode.All/Leading、dimAmount与 backdrop;全屏滑动返回默认关闭(仅系统边缘预测返回),可在设置中开启,且播放器、详情、WebView 等横滑冲突页面始终禁用。- 应用为每个 entry 提供
ProvideMiuixNavViewModelApplicationExtras,把Application注入defaultViewModelCreationExtras,弥补 Miuix 本地运行时的 ViewModel factory 缺省。
深链与状态接入:不依赖 Nav3 的能力
README 与升级评审共同说明:BiliPai 已有完整的深链处理链,并不存在需要靠 AndroidX Navigation 3 1.2 才能解决的功能缺口:
- MainActivity.kt 的
resolveIntentLinkAndNavigate处理外部入口,并等待b23.tv短链异步展开后再决定原生目标或网页兜底; - BilibiliNavigationTargetParser.kt 与 BilibiliLinkNavigationPolicy.kt 负责链接到业务目标的解析与分流;
- AppNavigation.kt 将业务目标交给现有路由与返回栈操作;
rememberNavBackStack<BiliPaiNavKey>使用可序列化的完整路由层级; BiliPaiNavDisplayHost使用 MiuixNavDisplay,本地运行时负责 entry 生命周期、保存状态与 ViewModel store,并补充 Application creation extras。
因此评审结论是:不新增 matcher、Result 总线或 Scene 架构;若未来需要导航结果返回或多窗格能力,应先定义具体业务行为,再决定应用层实现还是切换运行时。
测试与验证边界
本模块的测试分两层:
- 模块内:PredictiveBackOwnershipTest.kt 与 NavPredictiveBackDriverTest.kt 覆盖所有权代际与指跟随驱动;前者验证迟到的取消不能释放被重新抓取的会话,后者验证驱动器的锚定与线性跟随语义。
- 应用侧:VideoCardBackCompletionPolicyTest.kt 锁定悬浮卡片的 0.65/0.35 边界、session 隔离与非法采样防护。
同时需注意 README 与评审文档共同声明的验证边界:依赖移除后的完整依赖图和设备行为尚未验证,更新的 Kotlin 测试也未运行;现有其他结构测试仍有针对历史 Nav3 实现的断言。因此,模块当前的状态是“源码级设计与单元测试已验证、全套集成验证待补”,这在阅读与引用时应如实表述。
小结
BiliPai 的miuix-navigation是一个“上游源码内联 + 本地策略补丁 + 应用深度集成”的典型案例:通过捆绑固定版本保证补丁可复现,通过NavBackCompletionPolicy接口把“何时完成返回”的决策权交给应用(VideoCardBackCompletionPolicy的 0.65/0.35 悬浮卡片状态机),通过所有权代际与 session 隔离保证旧手势绝不干扰新手势,再配合BiliPaiNavDisplayHost完成视频卡片的 97% 缩放形变与快照协调。对于希望深度定制导航运行时、又不愿被上游版本节奏绑架的 Compose Multiplatform 项目,这套“fork 源码 + 最小补丁面 + 阈值化策略接口 + 单元测试锁定边界”的方法论可以直接借鉴。
- 移动开发
- 音视频
- 插件系统
【免费下载链接】BiliPai
原生、纯净、可扩展 —— 重新定义你的 B 站体验
相关推荐
BiliPai 导航运行时选型与 Miuix 导航源码级定制:一次面向 Nav3 升级的评审复盘
BiliPai 导航运行时选型与 Miuix 导航源码级定制:一次面向 Nav3 升级的评审复盘 本文围绕 BiliPai 仓库中的 miuix navigat
移动开发音视频插件系统5分钟跑通Python抢票脚本:大麦网自动下单保姆级玩法
5分钟跑通Python抢票脚本:大麦网自动下单保姆级玩法 开票前30秒,你连点11次刷新,页面还是白屏。等点进详情页,"已售罄"已经亮了。人手点不过程序发请求,
网页爬虫工作流自动化LoveIwara 启用 Android 预测式返回手势:Flutter 多导航栈下方案 A 的设计与源码剖析
LoveIwara 启用 Android 预测式返回手势:Flutter 多导航栈下方案 A 的设计与源码剖析 本文以 LoveIwara(i iwara)仓库
移动开发桌面应用音视频视频
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考