为什么我要自己写一个Navigation

Fragment的地位在提升

传统来说,Android APP中的页面应该是以多个Activity去组织的,Fragment往往只适合在Activity中挖出一块,用于展示便于切换的碎片页面。

随着Jetpack Navigation(此处主要指的是navigation-fragment)的推出,Fragment的地位开始有所提高。Navigation推荐我们用多个Fragment去展示单个业务下的多个页面,仿佛渐渐取代了传统的Activity,当上了主角。

甚至,我们可以考虑一整个app都只在一个Activity容器上承载,所有页面都通过Fragment去实现,这就是Single activity application。早在多年前,Android官方推出Navigation时就提出了这种设想(Single activity: Why, when, and how (Android Dev Summit '18))。这样做有哪些激动人心的变化呢?

  • 更轻量的实现:Fragment比Activity更轻量,也不需要在AndroidManifest中定义
  • 更好的性能:启动Activity涉及与系统服务的跨进程通信,而启动Fragment则简单得多
  • 信息传递:通过共享ViewModel去传递参数,比通过Intent去给另一个Activity传参更简单和灵活
  • 全局弹窗:是否遇到过弹窗还没处理的时候发生了Activity跳转,此时弹窗就被挤掉了。只有一个Activity时,弹窗就是全局的,获得和iOS一样的全局弹窗体验。
  • 无需申请权限的应用内浮窗:我们知道使用浮动窗口是需要向系统申请相关权限的,如果我们只需要一个应用内的"浮窗",那只要往Activity的布局上添加这个"浮窗",它就可以"浮动"在所有页面的顶上,得到一个应用内浮窗的效果。

⚖ 那代价呢

凡事总有利弊,Single Activity Application带来好处的同时也引入了一些风险:

  • Fragment的生命周期比Activity更复杂
  • Fragment的回退栈不好管理,且调试时无法用adb指令dump出来
  • 屏幕方向等Activity配置难以管理

为了方便开发者实现多Fragment的路由,Jetpack推出了Navigation这个最早是用于控制Fragment路由导航的框架。

作为官方推出的框架,介绍它的文章自有不少,这里就不展开。

我也亲身使用过一段时间,确实能解决一些问题,但也同时有很多痛点

  • 用xml定义路由表,与代码定义的Fragment有点割裂,且写法复杂
  • 无法保持之前的Fragment状态
  • 除了自行控制,在进入或返回时,不确定能否保持Fragment的屏幕方向,是否全屏等属性
  • 用id资源来做路由地址,除非用DeepLink
  • 缺乏路由拦截器机制

写完Fragment后还要去navGraph的xml去定义一下,实在是麻烦,我甚至连layout的xml都不想写

和layout xml说拜拜 BrickUI,基于Android View体系撸一个声明式UI框架

如果让我来写一个Fragment路由框架

我开始考虑,如果我要去做一个Single activity application,我需要一个怎样的路由框架?

  • 直接在Fragment上定义路由信息
  • 可以选择是否保持历史Fragment的状态
  • 可以在去到或回到Fragment时,就像Activity一样,恢复其横竖屏、全屏等窗口属性,而不需要额外控制
  • 可以通过uri来配置路由地址和传参,一个页面支持配置多个路由地址
  • 具有路由拦截器机制,拦截器可以动态装载和卸载,拦截器有优先级区分
  • 既然能传参,那还应该可以返回结果给上一个页面,类似onActivityResult
  • 支持类似Activity的LaunchMode

这个路由框架已经写好了 👇🏻👇🏻👇🏻

🐱 github.com/robin8yeung...

Blink,名字取自dota游戏中的闪烁技能。欢迎大家来star一下⭐️⭐️⭐️

定义Activity容器

容器Activity用于承载Fragment,为了使blink-fragment框架正常运行,有以下要求:

  • 需要继承抽象类BlinkContainerActivity
  • 禁止系统设置变化导致Activity重建
kotlin 复制代码
class FragmentContainerActivity: BlinkContainerActivity() {
    // 首个展示的Fragment,不希望写死也可以返回null,后续通过blink()方法来跳转
    override fun startFragment() = HomeFragment()

    // 其他业务代码
}

由于Activity重建会导致一系列问题,不太好解决,如结果返回,状态维护等,所以现阶段禁止Activity重建,请在AndroidManifest.xml中对容器Activity的android:configChanges进行以下配置:

xml 复制代码
<activity android:name="com.seewo.blink.example.fragment.FragmentContainerActivity" 
          android:configChanges="mcc|mnc|navigation|orientation|touchscreen|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"/>

定义一个Fragment

通过注解即可在定义Fragment的地方定义好它的路由地址,以及它的一系列页面属性。当然,这些页面属性的定义不是必须的。

kotlin 复制代码
object Uris {
    const val fragment = "blink://my.app/fragment"
    const val HOME = "blink://my.app/home"
}

// 为MyFragment定义一个或多个路由uri
@BlinkUri(value = [Uris.fragment, Uris.HOME])
// 定义页面方向为竖屏,当来到或回到这个页面时,屏幕方向都将切换为竖屏
@Orientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
// 自定义转场动画
@CustomAnimations(
    enter = R.anim.enter_from_bottom, exit = R.anim.fade_out,
    popEnter = R.anim.fade_in, popExit = R.anim.exit_to_bottom)
// 定义页面进入回退栈后不再保持状态(即通过replace切换到新的页面)
@KeepAlive(false)
// 设置SystemUI样式,当来到或回到此页面时,SystemUI样式更新为以下配置
@SystemUI(
    hideStatusBar = true,
    hideNavigationBar = true,
    brightnessLight = false,
)
// 设置页面的背景颜色,类似于Activity设置window的背景颜色
@Background(Color.TRANSPARENT)
class MyFragment : Fragment() {
    // ....
}

🚀 LaunchMode

前面说了,我们还可以定义LaunchMode,但不是通过注解来定义。不是不行,而是类似Activity需要在onNewIntent中去接收二次打开时的新Intent。而blink-fragment定义了相关抽象类来提供相应功能。

kotlin 复制代码
// 为MyFragment定义LaunchMode为singleTop,继承SingleTopFragment即可
@BlinkUri(Uris.fragment)
class MyFragment : SingleTopFragment() {
    override fun onNewArguments(arguments: Bundle?) {
        // 重复打开时,会回调此方法
    }
}

// 为MyFragment定义LaunchMode为singleTask,继承SingleTaskFragment即可
@BlinkUri(Uris.fragment)
class MyFragment : SingleTaskFragment() {
    override fun onNewArguments(arguments: Bundle?) {
        // 重复打开时,会回调此方法
    }
}

这里没有SingleInstance模式,需要的话可以自行开一个新的Activity。

🚥 路由表初始化

只用@BlinkUri定义了路由地址实际上还无法生效,它只是便于初始化路由表在执行KSP时收集信息。

对于多module的项目,每个定义过@BlinkUri的module中,都需要实现一个RouteMetadata,在初始化的时候,如Application的onCreate,调用每个RouteMetadata的inject()来把module的路由表注入到全局路由表之中。

如果不希望module的逻辑侵入app module,也可以借助Jetpack startup框架了来执行module内部的初始化

为什么不用ASM的方式来简化这个步骤呢?我的考虑是编译时插桩容易在编译侧造成开销,而这个初始化对于每个module,只需要写一点代码就可以一劳永逸,最终还是决定稍微难为一下开发者。

kotlin 复制代码
// 用@BlinkMetadata注解定义一个路由表的初始化入口,为了简化实现,请继承BaseMetadata
@BlinkMetadata
class RouteMetadata : BaseMetadata()

// lib module建议用startup框架来实现初始化,也可以在Application的onCreate中对所有模块的BaseMetadata子类进行初始化调用
class AvatarInitializer : Initializer<Unit>{
    override fun create(context: Context) {
        // 初始化,注入module的路由表到全局路由表,建立uri与页面的映射关系,否则无法实现路由跳转
        RouteMetadata().inject()
    }

    override fun dependencies(): MutableList<Class<out Initializer<*>>> =
        mutableListOf()
}

传参与返回

🎁 传参与返回回调

blink-fragment基于uri参数来传参,也提供了简洁的方式来创建uri。

此外通过blink()函数的回调参数即可接收下个页面返回的数据,是不是比传统Activity的onActivityResult方便多了?

kotlin 复制代码
object Uris {
    const val HOME = "blink://my.app/home"
}

// 以下两种Uri的构造方式是等效的,都可以路由到@BlinkUri定义为Uris.HOME的页面并传参
fragment.blink("${Uris.HOME}?name=Peter&age=8") {
    // 此处接受返回回调,返回的结果是个Bundle?类型
}

fragment.blink(Uris.HOME.buildUri {
    append("name", "Peter")
    append("age", "8")
}) {
    // 此处接受返回回调,返回的结果是个Bundle?类型
}

📮 接收参数与返回结果

blink-fragment提供了一系列简单的接收参数的操作符,也可以通过by lazy的方式来自行处理复杂的接受参数的操作

kotlin 复制代码
@BlinkUri(Uris.HOME)
class HomeFragment : Fragment() {

    // 开发者通过by lazy自行处理Name参数传入
    private val name: String? by lazy { arguments?.uriOrNull?.getQueryParameter("name") }

    // 由Blink提供懒加载函数进行参数注入,默认值可选。
    private val age: Int by intParams("age", 18)
    
    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        findViewById<View>(R.id.button).setOnClickListener {
            // 点击按钮,返回Bundle结果
            pop(Bundle().apply {
                putInt("result", 1)
            })
        }
        findViewById<View>(R.id.cancel).setOnClickListener {
            // 点击取消,直接返回,此时路由发起方的回调则会接收到一个null数据
            pop()
        }
    }
}

拦截器

通过拦截器可以方便的拦截某些路由或对路由进行重定向,修改参数等。blink-fragment的拦截器支持动态的添加和移除,也支持优先级的定义

kotlin 复制代码
// 这里仅用于举例,真实使用时,建议拦截器职责单一
class ExampleInterceptor : Interceptor {
    override fun process(from: Fragment?, target: Bundle) {
        val uri = target.uriOrNull
        // 打印路由信息
        Log.i("blink", "[from] $from [to] $uri")
        // 获取路由请求的参数,修改path并增加参数
        target.setUri(uri?.build {
            path("/another")
            append("new", true)
        })
        // 对于缺少权限的情况,拦截跳转
        if (!Permission.hasCameraPermission) {
            interrupt("缺少必要权限")
        }
        // 如果权限具备,则继续跑到下一个拦截器或者跑完了所有拦截器则执行路由
    }
}

val exampleInterceptor = ExampleInterceptor()

// 添加拦截器
exampleInterceptor.attach()
// 移除拦截器
exampleInterceptor.detach()

🚒 异常处理

既然路由可能被拦截,就要考虑做异常处理。blink()函数返回的是一个Result<Unit>,可以对Result处理异常。

路由失败的原因主要有:

  • FragmentNotFoundException 无法找到uri对应的Fragment
  • 自定义异常 被路由拦截,拦截器调用interrupt()时,默认抛InterruptedException来拦截拦截,也支持自定义拦截异常
kotlin 复制代码
blink("blink://navigator/example?name=Blink").onFailure {
    // 处理异常
}.onSuccess {
    // 路由成功
}

实现原理

blink-fragment的原理并不复杂,主要做了几件事:

🏡 为每个Fragment分配容器

通过blink-fragment定义的Fragment实际上并不是直接插入BlinkContainerActivity中的,而是在其外层还包了一层BlinkContainerFragment,BlinkContainerFragment作为容器,为实际的Fragment提供了背景颜色,属性管理等的相关支持,也就是实际Fragment通过注解定义的除了BlinkUri以外的属性,都记录在了这个容器中,当来到或回到这个页面时,它就会让这些属性生效,免去Fragment对这些逻辑的关心。

🌏 生成路由表

借助ksp框架,在编译时扫描开发者定义在module内定义的BlinkUri,并为该module生成路由表。再把路由表信息写入到被@BlinkMetadata注解的类中,为其创建一个_inject()函数,用于注入全局路由表。最终调用到了这个_inject()函数即可完成路由表的初始化。而_inject()函数的功能,即是往全局路由表单例RouteMap中注册该module的路由表信息。

🚀 执行路由

通过调用blink(uri)来执行路由导航时,uri会经过每一个拦截器处理,如果未被拦截,则最终输出一个最终uri,此时即可到全局路由表RouteMap中去查找uri所对应的Fragment。如果无法查找到Fragment,则抛出FragmentNotFoundException;如果能查找到对应的Fragment,则创建一个BlinkContainerFragment容器去装载这个Fragment,并且获取其注解的相关参数,并生成一个唯一标识符,最终把这个BlinkContainerFragment根据所注解的参数,装载到BlinkContainerActivity

✉️ 结果返回

blink-fragment的路由功能本身基于一个Blink单例来实现,其也管理了一个收集回调的映射表,映射表的key为目标Fragment的唯一标识符。当调用pop(bundle)返回时,通过这个标识符即可查找到对应的结果回调,回调给路由来源

总结

目前blink-fragment已经接入到一些实际的项目中,也有着不错的开发效率收益。不过如果要做到Single Activity Application,可能对于新项目会更适合,毕竟对于成熟项目,把一个个Activity改成Fragment,工作量和风险着实不小。

本文适合的场景有限,所以仅当给大家拓宽个思路。如果有不合理和考虑不周的地方,也希望可以和大家友好讨论。

最后如果本文对你有帮助,就求点赞求评论求收藏,给个一键三连吧~🎉

相关推荐
恋猫de小郭2 小时前
Flutter 发布官方 Skills ,Flutter 在 AI 领域再添一助力
android·前端·flutter
Kapaseker7 小时前
一杯美式搞懂 Any、Unit、Nothing
android·kotlin
黄林晴7 小时前
你的 Android App 还没接 AI?Gemini API 接入全攻略
android
恋猫de小郭17 小时前
2026 Flutter VS React Native ,同时在 AI 时代 VS Native 开发,你没见过的版本
android·前端·flutter
冬奇Lab18 小时前
PowerManagerService(上):电源状态与WakeLock管理
android·源码阅读
BoomHe1 天前
Now in Android 架构模式全面分析
android·android jetpack
二流小码农1 天前
鸿蒙开发:上传一张参考图片便可实现页面功能
android·ios·harmonyos
鹏程十八少1 天前
4.Android 30分钟手写一个简单版shadow, 从零理解shadow插件化零反射插件化原理
android·前端·面试
Kapaseker1 天前
一杯美式搞定 Kotlin 空安全
android·kotlin
三少爷的鞋1 天前
Android 协程时代,Handler 应该退休了吗?
android