news 2026/10/15 2:51:09

BiliPai 的 Miuix 导航深度集成:从源码捆绑到预测式返回策略定制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
BiliPai 的 Miuix 导航深度集成:从源码捆绑到预测式返回策略定制
  • 移动开发
  • 音视频
  • 插件系统

【免费下载链接】BiliPai

原生、纯净、可扩展 —— 重新定义你的 B 站体验

项目地址:https://gitcode.com/gh_mirrors/bil/BiliPai
点击查看免费下载

导读

本文围绕 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)。
  • 本地化适配:上游代码中的 Androidexpect/actual声明被解析为普通 Android 函数,使该模块在 Android 目标上可直接编译运行。
  • 构建意义:应用(app模块)直接依赖project(":miuix-navigation")而不是发布到 Maven 的miuix-navartifact,从而保证补丁在 CI 与本地构建中可复现。README 同时提醒:升级这些源码时,其余 Miuix 模块应保持匹配的版本。

这种“源码内联 + 补丁可复现”的做法,核心目的是让NavDisplay、NavBackStack等导航运行时的行为对 BiliPai 完全可控,而不是被上游发布节奏牵制。

版本固定与升级评审:九个提交背后的决策

模块内还附有一份中文升级评审文档 miuix-navigation/UPGRADE_REVIEW.md(评审日期 2026-10-02),记录了版本决策的完整论证过程,要点如下:

  1. 版本事实:gradle/libs.versions.toml 将版本固定为0.9.4-5c91d5e5-SNAPSHOT,该版本处于上游正式版v0.9.4之后九个提交,其中包含“导航切换时清除焦点”的修复。
  2. 不降级:评审时核对了v0.9.4到5c91d5e5的提交差异,确认5c91d5e5之后的上游 main 分支提交仅涉及示例与 README、未修改miuix-nav;因此降回0.9.4只会回退已合入的修复,故保持固定版本与本地导航源码。
  3. 不引入第二套运行时:miuix-nav不依赖androidx.navigation3,尽管两者的 NavKey、NavDisplay 与 back stack API 表面相似,但 Google 的 Scene、Result 与 Deep Link API 并不会自动成为 Miuix Nav 的功能,因此没有必要“仅为了 Deep Link 就整体迁移到 Navigation 3”。
  4. 本次收敛:移除没有生产代码引用的lifecycle-viewmodel-navigation3依赖、移除未使用的远程miuix-navigationcatalog 别名,并更新了两个旧结构测试——从“要求 Nav3 alpha07 与 entry decorators”改为“检查本地 Miuix 接入及无旧 Nav3 依赖”。
  5. 保留历史命名:应用的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 站体验

项目地址:https://gitcode.com/gh_mirrors/bil/BiliPai
点击查看免费下载

相关推荐

上一篇:NVIDIA Profile Inspector完整指南:5步解锁显卡隐藏性能,告别游戏卡顿
下一篇:claude-code-router Fusion 自定义 MCP 工具接入指南:从本地/远程 MCP 到图片与视频生成

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 2:44:39

Flutter 图文混排实战:商品列表布局与滚动性能优化

1. 从一张商品卡片说起&#xff1a;图文混排到底难在哪做移动端电商项目的人&#xff0c;迟早都会撞上「商品列表图文混排」这堵墙。表面上看&#xff0c;无非就是左边一张图、右边几行字&#xff0c;或者上面一张大图、下面标题价格&#xff0c;用Row和Column拼一拼就完事了。…

作者头像 李华
网站建设 2026/10/15 2:43:28

基于Springboot社区医院管理系统【附源码+文档】

&#x1f495;&#x1f495;作者&#xff1a; 米罗学长 &#x1f495;&#x1f495;个人简介&#xff1a;混迹java圈十余年&#xff0c;精通Java、小程序、数据库等。 &#x1f495;&#x1f495;各类成品Java[毕][设] 。ssm&#xff0c;springboot&#xff0c;vue等项目&#…

作者头像 李华
网站建设 2026/10/15 2:42:20

指纹浏览器怎么使用?从创建浏览器配置到团队协作的完整教程

管理多个电商店铺、社媒账号或广告账户时&#xff0c;平台总能判定“关联”&#xff0c;团队协作时账号密码传来传去也不安全。指纹浏览器能为每个浏览器配置文件保存独立的浏览环境&#xff0c;包括 Cookie、本地存储和浏览器指纹&#xff0c;让不同账号在同一台电脑上完全隔离…

作者头像 李华