美文网首页
Android组件化架构 —— 基础(四) URL Scheme

Android组件化架构 —— 基础(四) URL Scheme

作者: 雷小歪 | 来源:发表于2021-12-10 15:58 被阅读0次
    xwzz.jpg

    前篇回顾

    前篇,我们了解了ARouter路由的基本功能及其内部原理。至此,我们已完成Android APP组件化架构的搭建,并解决了组件间的通讯问题。
    但,正如上述描述,我们仅是解决了Android APP内部组件通讯。实际开发中,通讯不仅涉及Android APP内部,往往还涉及到一些外部使用场景,如:

    • 1、接口路由下发,动态页面跳转或功能调起
    • 2、外部APP唤起咱们APP的页面或功能
    • 3、Web H5 js调用APP的页面或功能

    我们知道,这些场景并不属Android APP独有,iOS、H5、后台接口都涉及在内,需各端相互配合才能完成上述场景的相关功能,那么各端之间统一的通讯规则就显得尤为重要,URL Scheme或许是不二之选。

    URL Scheme

    我们知道一个正常的Url链接是这样的:

    https://www.baidu.com?key=hello
    

    它是由scheme协议头、host域名、path路径、query参数4个部分组成

    [scheme:][host][path][?query] 
    

    无论是Android,还是iOS都支持开发人员为自身APP注册自定义的URL Scheme,便于其它APP与之通讯。

    如何设计一个满足我们业务场景需求的URL是我们首先要解决的问题,通过上面3个场景需求的分析,我们知道设计的URL无非就是将 “页面跳转”“功能调用” 这两个需求高效或者说显著的表示出来。

    ARouter的URL Scheme

    前篇,ARouter中有提到其是支持标准URL Scheme跳转的,我们先尝试直接使用ARouter看是否能满足我们的需求,以第2个场景为例,由外部APP唤起user模块的UserMainActivity和isLogin()功能。

    • ARouter - 标准的页面跳转URL定义
    页面路由:example://www.demo.com/user/UserMainActivity
    

    上面是我定义的跳转UserMainActivity路由,只需在AndroidManifest.xml中注册这个Scheme,再由ARouter完成跳转。

            <activity android:name=".AppMainActivity">
              
                ...
    
                <intent-filter>
                    <data
                        android:host="www.demo.com"
                        android:scheme="example"/>
    
                    <action android:name="android.intent.action.VIEW"/>
    
                    <category android:name="android.intent.category.DEFAULT"/>
                    <category android:name="android.intent.category.BROWSABLE"/>
                </intent-filter>
    
            </activity>
    
    // Activity
    class AppMainActivity : AppCompatActivity() {
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_main)
            handUri();
        }
    
        private fun handUri() {
            val uri: Uri? = intent.data
            if (uri != null) {
                // 交给ARouter处理
                ARouter.getInstance().build(uri).navigation()
            }
        }
    }
    

    外部,我使用浏览器来唤起我们的APP

    <a href="example://www.demo.com/user/UserMainActivity">跳转APP</a>
    

    测试下:


    ARouter标准Url跳转页面
    • ARouter - 标准的功能调用URL定义
    功能路由:example://www.demo.com/user/isLogin
    

    可当我按照标准的URL规范去定义功能调用url时,ARouter并不会明白这个url所表达的意义。通过查看源码我们会发现ARouter会将url中path部分作为它自身的路由部分来处理后续逻辑。

    final class _ARouter {
    
        ...
    
        protected Postcard build(Uri uri) {
            if (null == uri || TextUtils.isEmpty(uri.toString())) {
                throw new HandlerException(Consts.TAG + "Parameter invalid!");
            } else {
                PathReplaceService pService = ARouter.getInstance().navigation(PathReplaceService.class);
                if (null != pService) {
                    uri = pService.forUri(uri);
                }
                //url的path会作为ARouter的path进行处理
                return new Postcard(uri.getPath(), extractGroup(uri.getPath()), uri, null);
            }
        }
    
    }
    

    显然,功能路由的/user/isLogin是无法匹配到路由的,还记得ARouter跨模块功能调用提供的路由是谁的吗?没错Servcie!一个Service下可能存在多个功能,而isLogin()只是其中一个。我们通过路由也仅仅能拿到Service对象,如果想要通过url调到具体的service内部功能,还要编写大量的代码来进行匹配,并且这样的代码维护性是很差的。

    因此,我个人不建议直接将URL Scheme交给ARouter来处理,ARouter只需做好本职APP内部通讯即可,对于Url的处理可以由上层造轮子交给它解析后,再由ARouter来完成跳转或者功能调用。

    自定义URL Scheme

    既然,Url需交给上层轮子来处理,那么对于Url的定义也就无需参考ARouter规范约束,实际开发中Url的定义往往也不会理想化的完全按照ARouter要求来设计,毕竟适合多端协作的Url才是好的Url,下面以我定义的Url格式为例,开始制造这个轮子。

    example://www.demo.com/openApp?action={
         "action_type":"jump" ,                     // 动作类型  跳转(jump) 或 功能(call)
         "page_type":"native" ,                     // 跳转的页面类型  native  web  rn  flutter
         "path":"/user/UserMainActivity" ,          // 实际路由地址
         "params":"{ \"key\" : \"value\" }"          // 携带参数 
    }
    

    可以看到[scheme:][host][path]这三部分是固定不变的,涉及业务逻辑都由参数action字段来决定,action主要由四部分组成:

    • action_type : 标识路由动作,jump(页面跳转) ,call(功能调起)
    • page_type : 当路由动作为jump时,标识页面跳转目标类型,native (App原生页面),web(H5页面),rn(RN页面),flutter(Flutter页面)
    • path : 实际的路由地址
    • params : 目标页面或功能所需参数

    这四部分基本可以满足页面跳转和功能调起需求了,action对应的Bean对象如下:

    data class RouterAction(
        var action_type: String? = "",    // 行为类型:  jump (跳转页面) 、 call (调用功能)
        var page_type: String? = "",      // 页面类型:  native (原生页面) 、 web (H5) 、rn(RN)、 flutter(Flutter)
        var path: String? = "",           // 路由地址:  /search/SearchActivity
        var params: JsonObject? = null    // 路由目标需要的参数: "{ key : value }"
    )
    

    自定义页面跳转 Url

    OK,有了路由规则,来看看页面跳转现在的路由是什么样?

    页面路由:
    example://www.demo.com/openApp?action={"action_type":"jump" ,"page_type":"native","path":"/user/UserMainActivity" , "params":"{}" }
    

    有了路由,开始完成文章开篇时提到的三个场景吧!

    场景1:接口路由下发,动态页面跳转

    这是一个很常见的场景,例如:
    订单详情页底部按钮,在不同的订单状态下会显示不同的业务逻辑按钮(申请退款、取消订单、评价、联系客服等),以往这些按钮的显示规则都由移动开发人员写死在客户端代码里,一旦订单业务进行调整,原显示规则发生改变,就需修改客户端代码,诺流程影响过大,甚至需开启强更发版。
    现在有了路由,这些显示规则都可搬移至服务端,由后台开发人员动态返回。

    下面我将模拟接口返回路由场景,涉及JSON数据如下:

    {
        "btnBgColor":"#FF0000",
        "btnColor":"#FFFFFF",
        "btnTxt":"跳转User页面",
        "openUrl":"example://www.demo.com/openApp?action\u003d{\"action_type\":\"jump\" ,\"page_type\":\"native\",\"path\":\"/user/UserMainActivity\" , \"params\":{} }"
    }
    

    模拟请求代码:

        private fun getBtnForNet() {
            Thread {
                Thread.sleep(1500)
                val json = "{\"btnBgColor\":\"#FF0000\",\"btnColor\":\"#FFFFFF\",\"btnTxt\":\"跳转User页面\",\"openUrl\":\"example://www.demo.com/openApp?action\\u003d{\\\"action_type\\\":\\\"jump\\\" ,\\\"page_type\\\":\\\"native\\\",\\\"path\\\":\\\"/user/UserMainActivity\\\" , \\\"params\\\":{} }\"}"
                runOnUiThread {
                    showButton(JsonParser.fromJsonObj(json, ButtonBean::class.java))
                }
            }.start()
        }
    
        private fun showButton(btnBean: ButtonBean) {
            val button = Button(this)
            btnBean.btnColor?.run {
                button.setTextColor(Color.parseColor(this))
            }
            btnBean.btnBgColor?.run {
                button.setBackgroundColor(Color.parseColor(this))
            }
            button.text = btnBean.btnTxt
           
            button.setOnClickListener {
                RouterManager.jumpUrl(this, btnBean.openUrl)
            }
    
            findViewById<ViewGroup>(R.id.layoutParent).addView(button)
        }
    

    可以看到,我将路由处理交给了RouterManager的jumpUrl()函数,实际上RouterManager只是我对ARouter API的封装管理类,最终我将URL Scheme的处理交给了前文提到要造的轮子:SchemeHelper

    /**
     * 路由管理类
     * */
    object RouterManager {
    
        private val mSchemeHelper by lazy { SchemeHelper() }
      
        /**
         * 跳转 Activity
         * */
        fun goActivity(context: Context?, path: String, bundle: Bundle? = null) {
            ARouter.getInstance().build(path)
                .with(bundle)
                .navigation(context)
        }
    
        ...
    
        /**
         * Scheme 路由跳转
         * */
        fun jumpUrl(
            context: Context,
            jumpUrl: String?,
            callBefore: ((context: Context, action: RouterAction) -> Unit)? = null,
            callAfter: ((context: Context, json: String?) -> Unit)? = null
        ) {
            mSchemeHelper.jumpUrl(context, jumpUrl, callBefore, callAfter)
        }
    
        ...
    
    }
    

    而SchemeHepler中要做的事想当然是对Url进行解析,拿出参数action中那四部分数据,根据这四部分数据完成具体的页面跳转。

    class SchemeHelper {
    
        /**
         * Scheme 路由跳转
         * */
        fun jumpUrl(
            context: Context,
            jumpUrl: String?,
            callBefore: ((context: Context, action: RouterAction) -> Unit)? = null,
            callAfter: ((context: Context, json: String?) -> Unit)? = null
        ) {
            try {
                XLog.i("jumpUrl:: $jumpUrl")
                //校验协议
                if (TextUtils.isEmpty(jumpUrl)) return
                val schemeUrl = CommRouter.Scheme.run { "$SCHEME$HOST$PATH" }
                if (jumpUrl!!.startsWith(schemeUrl)) {
                    // 解析Action
                    var actionJson = UrlUtils.getUrlParam(jumpUrl, CommRouter.Scheme.ACTION)
                    actionJson = URLDecoder.decode(actionJson)
                    val routerAction = JsonParser.fromJsonObj(actionJson, RouterAction::class.java)
                    // 分发路由
                    dispatchAction(context, routerAction, callBefore, callAfter)
                }
            } catch (e: Exception) {
                XLog.e(e)
            }
        }
    
        /**
         * 根据action_type分发路由
         * */
        private fun dispatchAction(
            context: Context,
            routerAction: RouterAction,
            callBefore: ((context: Context, action: RouterAction) -> Unit)? = null,
            callAfter: ((context: Context, json: String?) -> Unit)? = null
        ) {
            if (TextUtils.isEmpty(routerAction.action_type)) return
    
            when (routerAction.action_type) {
                CommRouter.Scheme.ACTION_TYPE_JUMP -> {
                    // 跳转页面
                    jumpAction(context, routerAction, callBefore, callAfter)
                }
                CommRouter.Scheme.ACTION_TYPE_CALL -> {
                    // 调用功能
                    callAction(context, routerAction, callBefore, callAfter)
                }
                else -> {
                    XLog.e("未知行为类型(action_type)::${routerAction.action_type}")
                }
            }
        }
    
        ...
    }
    

    通过路由分发,我们已将Url解析为路由跳转(jumpAction)和功能调用(callAction)两部分,我们知道现在接口下发的是“/user/UserMainActivity”页面跳转路由,这个路由不就是配置在UserMainActivity上的ARouter路由吗,剩下的就交给ARouter吧!

        /**
         * 跳转页面
         * */
        private fun jumpAction(
            context: Context,
            routerAction: RouterAction,
            callBefore: ((context: Context, action: RouterAction) -> Unit)? = null,
            callAfter: ((context: Context, json: String) -> Unit)? = null
        ) {
            if (TextUtils.isEmpty(routerAction.page_type) || TextUtils.isEmpty(routerAction.path)) return
    
            callBefore?.invoke(context, routerAction)
    
            when (routerAction.page_type) {
                CommRouter.Scheme.PAGE_TYPE_NATIVE,
                CommRouter.Scheme.PAGE_TYPE_WEB -> {
                    // 原生页面 & H5 处理方式一样 ,都交给ARouter处理
                    RouterManager.goActivity(
                        context,
                        routerAction.path!!,
                        bundle
                    )
                }
                CommRouter.Scheme.PAGE_TYPE_RN -> {
                    // RN
    
                }
                else -> {
                    XLog.e("未知页面类型(page_type)::${routerAction.page_type}")
                }
            }
            callAfter?.invoke(context, "")
        }
    

    来,运行下看看效果:


    接口下发页面路由演示

    自定义功能调起 Url

    页面跳转完成,接下来看看功能调起的路由又该如何实现。

    功能路由:
    example://www.demo.com/openApp?action={"action_type":"call" ,"page_type":"","path":"/user/isLogin" , "params":"{}" }
    
    场景1:接口路由下发,动态功能调起

    思考下:开篇时,我们提到ARouter是无法直接通过路由去调起一个功能,还需要借助Service来做中转,“/user/isLogin”又属于哪个Service呢,对于User模块的开发者知道这个功能属于他的模块,但对于其他开发人员就不一定了。如果每个业务模块对外提供一个功能,都在SchemeHelper这个轮子里进行编写代码调起自身模块的Service功能,显然是不合适的!怎么办?

    还记的 “Android组件化 —— 基础(二) - 组件间通讯” 篇章中我们是如何手动实现路由框架的吗?这里是类似的,对于功能调起的路由需要开发人员手动注册到SchemeHelper中,至于使用哪个Service,调起哪个功能,都不应该由轮子来操心,回调给注册的开发人员去实现即可。

    下面是我定义的回调函数接口:

    interface IRouterCall {
    
        /**
         * @param context 上下文
         * @param path 功能路由
         * @param bundle 携带来的参数
         * @return 该功能可以返回数据,JSON格式字符串
         * */
        fun handleCall(context: Context, path:String , bundle: Bundle): String?
    }
    

    业务开发人员将对外提供的功能函数编写完毕后,再实现一个对应的IRouterCall子类,并将该子类对象与其对应的路由注册到SchemeHelper中。

    /**
     * User模块对外提供的ARouter Service
     * */
    @Route(path = "/user/UserService")
    class IUserServiceImpl2 : IUserService2 {
    
        override fun init(context: Context?) {
        }
    
        /**
         * 用户是否登录
         * */
        override fun isLogin(): Boolean {
            // 是否登录业务逻辑
            return true
        }
    }
    
    /**
     * /user/isLogin功能路由的处理类
     */
    class IsLoginCall : IRouterCall {
    
        override fun handleCall(context: Context, path: String, bundle: Bundle): String? {
            val ret = RouterManager.getService(IUserService2::class.java)?.isLogin() ?: false
            Toast.makeText(context, "用户登录状态:$ret", Toast.LENGTH_SHORT).show()
            return null
        }
    }
    
    /**
     * User模块初始化入口
     * */
    object UserInit {
    
        fun init(context: Context) {
            initRouter()
        }
    
        private fun initRouter() {        
            // 注册功能路由 /user/isLogin
            RouterManager.addRouterCall("/user/isLogin" , IsLoginCall())
        }
    
    }
    

    与手动实现路由框架时相同,我会将路由路径以及Call对象存储到Map容器中,在路由页面跳转部分,已经将路由处理分发为jumpAction() 和 callAction()函数,这里在callAction()函数里进行匹配路由,再回调给开发人员即可。

    object RouterManager {
    
        private val mSchemeHelper by lazy { SchemeHelper() }
      
        ...
    
        /**
         * 注册自己业务的路由处理器
         * */
        fun addRouterCall(
            path: String,
            call: IRouterCall
        ) {
            mSchemeHelper.registerCall(path, call)
        }
    
        ...
    }
    
    
    class SchemeHelper {
    
        // actionType : call 路由容器
        private val mCallGroup = HashMap<String, IRouterCall?>()
    
        /**
         * 注册Call功能
         * */
        fun registerCall(path: String, call: IRouterCall) {
            mCallGroup[path] = call
        }
    
    
        /**
         * 调起功能
         * */
        private fun callAction(
            context: Context,
            routerAction: RouterAction,
            callBefore: ((context: Context, action: RouterAction) -> Unit)? = null,
            callAfter: ((context: Context, json: String?) -> Unit)? = null
        ) {
            if (TextUtils.isEmpty(routerAction.path)) return
    
            // 正常触发 call
            callBefore?.invoke(context, routerAction)
            var resultJson: String? = null
            val call = mCallGroup[routerAction.path]
            if (call != null) {
                // 自定义的Call功能实现
                resultJson = call.handleCall(context, routerAction.path!! , bundle)
            } else {
                when (routerAction.page_type) {
                   // TODO 公共的Call功能实现
                   handleCommCall(context, routerAction.path!! , bundle)
                }
            }
            callAfter?.invoke(context, resultJson)
        }
    
    }
    

    测试下,试试App模块是否能调起User模块的isLogin功能:

        private fun getBtnForNet() {
            Thread {
                Thread.sleep(1500)
                val json = "{\"btnBgColor\":\"#FF0000\",\"btnColor\":\"#FFFFFF\",\"btnTxt\":\"调起isLogin\",\"openUrl\":\"example://www.demo.com/openApp?action\\u003d{\\\"action_type\\\":\\\"call\\\" ,\\\"page_type\\\":\\\"\\\",\\\"path\\\":\\\"/user/isLogin\\\" , \\\"params\\\":{} }\"}"
                runOnUiThread {
                    showButton(JsonParser.fromJsonObj(json, ButtonBean::class.java))
                }
            }.start()
        }
    
    接口下发功能路由演示

    至此,SchemeHelper轮子基本成型,我们已经顺利打通动态的页面跳转和功能调用场景,而至于场景2、场景3的实现,将通讯数据换成Scheme Url,拿到Url后扔给轮子处理即可,我把核心代码贴在下方,就不分别演示了。

    场景2:外部APP唤起咱们APP的页面或功能
    • 清单文件配置Scheme:
        <application>
    
            <activity android:name=".AppMainActivity">
                ...
    
                <intent-filter>
                    <data
                        android:host="www.demo.com"
                        android:scheme="example"/>
    
                    <action android:name="android.intent.action.VIEW"/>
    
                    <category android:name="android.intent.category.DEFAULT"/>
                    <category android:name="android.intent.category.BROWSABLE"/>
                </intent-filter>
                
                ...
            </activity>
    
        </application>
    
    • Activity中获取Url,并交给轮子处理
    @Route(path = "/app/AppMainActivity")
    class AppMainActivity : AppCompatActivity() {
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_main)
    
            handUri();
        }
    
        private fun handUri() {
            RouterManager.jumpUrl(this, intent?.dataString) 
        }
    
    }
    
    场景3:Web H5 Js调用APP原生页面或功能
    • webView暴露JS接口
    mWebView.addJavascriptInterface(JsApi(mContext), “androidJs”)
    
    
    /**
     * 供H5调用的Js接口
     * */
    class JsApi(private val mContext: Context) {
    
        /**
         * 通过路由调起APP的页面或功能
         * */
        fun openApp(openUrl: String) {
            RouterManager.jumpUrl(mContext , openUrl)
        }
    
    }
    
    • H5 Js调用
    window.androidJs.openApp("example://www.demo.com/openApp?action={\"action_type\":\"call\" ,\"page_type\":\"\",\"path\":\"/user/isLogin\" , \"params\":\"{}\" }")
    

    小结

    本篇,我们学习了URL Scheme在组件化场景中的使用,它为多场景开发中通讯提供了统一标准,使业务实现更加灵活,一个设计完善的Url路由可使开发人员一眼就知其作用,从而降低代码维护成本;试想下在场景1和场景3中,如果不使用Url路由来做通讯,客户端开发人员就不得编写大量的代码来完善这些功能,而这些功能涉及到流程变动时往往伴随着发版,使用路由做通讯可使发版频次降低。

    虽然我们完成了Url路由的通讯功能,但在处理Call路由时,还是采取了手动注册的方式。通过前两篇学习,我们知道手动注册会在APP启动时通过startup来完成,这个路由可能并未被使用就已被载入内存中,导致额外内存开销。在学习ARouter过程中,发现其是通过APT(注解处理器)方案来完成注册相关工作。

    下篇,不妨参考ARouter的APT实现,编写一个完成Call路由注册的注解处理器。那么,我们下篇再见~

    相关文章

      网友评论

          本文标题:Android组件化架构 —— 基础(四) URL Scheme

          本文链接:https://www.haomeiwen.com/subject/kkbwgltx.html