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

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路由注册的注解处理器。那么,我们下篇再见~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容