美文网首页
业务代码参数透传满天飞?(二)

业务代码参数透传满天飞?(二)

作者: _Jun | 来源:发表于2022-11-30 16:49 被阅读0次

    引子

    项目中参数多级透传满天飞的情况很常见,增加了开发的复杂度、出错的可能、及维护的的难度。

    透传包括两种形式:

    1. 不同界面之间参数透传。
    2. 同一界面中不同层级控件间透传。

    该系列的目标是消除这两种参数透传,使得不同界面以及同一界面内各层级间更加解耦,降低参数传递开发的复杂度,减少出错的可能,增加可维护性。

    上一篇通过向前查询参数的方式解决了第一个 case,本篇先聚焦在第二个 case,即同一界面不同层级控件间的参数透传。

    透传举例

    比如下面这个场景:

    特效卡片的点击事件需要传入私参“type”,以表示属于哪个 tab 页。

    界面层级如下:素材集市用 EffectActivity 来承载,其中的标签栏下方是一个子 Fragment ,其中包含了 ViewPager 控件,该控件内部的每一个页又是一个 Fragment。

    埋点私参和上报时机分处于两个不同的页面层级。上报时机在最内层 Fragment 触发,而私参在 Activity 层级生成,遂需通过两层 Fragment 的透传。

    于是就会出现如下代码:

    // EffcetActivity.kt
    override fun showEffectListContent(index: Int, from: String?) {
        mVpEffectContent.adapter = SimpleFragmentStatePagerAdapter(supportFragmentManager).apply {
            mTitles = arrayOf("视频库", "音乐", "音效", "贴纸", "转场", "特效", "滤镜", "背景", "字幕", "字体")
            mCount = mTitles!!.size
            createFragment = { position ->
                when (position) {
                    0 -> {
                        val fragment = RemoteCenterFragment.newInstance( -1, true, 0, MaterialProtocol.SOURCE.MATERIAL_MARKET, 1, from, 0, 0)
                        val paramsParser = DeepLinkParamsParser(compileDeepLinkParams())
                        (fragment as IDeepLinkPage).setDeepLinkParams(paramsParser.deeplinkParams)
                        fragment
                    }
                    1 -> {
                        MaterialMusicFragment.newInstance(from,mSelectedTabId,mSelectedModelId)
                    }
                    2 -> {
                        MaterialAudioFragment.newInstance(mSelectedTabId,mSelectedModelId)
                    }
                    // 索引值到类型值的映射
                    3 -> {
                        EffectListFragment.newInstance(CommonConstant.SERVER.TYPE_VSTICKER)
                    }
                    4 -> {
                        EffectListFragment.newInstance(CommonConstant.SERVER.TYPE_TRANSITION)
                    }
                    5 -> {
                        EffectListFragment.newInstance(CommonConstant.SERVER.TYPE_EFFECT)
                    }
                    6 -> {
                        EffectListFragment.newInstance(CommonConstant.SERVER.TYPE_FILTER)
                    }
                    7 -> {
                        EffectListFragment.newInstance(CommonConstant.SERVER.TYPE_BACKGROUND)
                    }
                    8 -> {
                        EffectListFragment.newInstance(CommonConstant.SERVER.TYPE_SUBTITLE)
                    }
                    else -> {
                        EffectListFragment.newInstance(CommonConstant.SERVER.TYPE_FONT)
                    }
                }
            }
        }
        mVpEffectContent.currentItem = index
        mVpEffectContent.offscreenPageLimit = 10
        mTlEffectTabs.setupWithViewPager(mVpEffectContent)
    }
    

    EffectListFragment即使承载 ViewPager 的 Fragment,上述代码在构建其实例时做了分类讨论,目的是为了根据不同类型的 tab 透传相应的 type 值。

    EffectListFragment 不得不先接受透传参数并继续传递到下一个层级:

    class EffectListFragment : BaseMvpFragment{
        // 保存透传参数的变量
        private var mCurrentType: Int = -1
        override fun initConfig(savedInstanceState: Bundle?) {
            // 获取透传参数
            mCurrentType = arguments?.getInt(CommonConstant.EFFECTCENTER.TYPE) ?: 0
            selectHotTab()
            showEffectDetails()
        }
    
        override fun showEffectDetails() {
            mVpEffectDetails?.adapter = SimpleFragmentPagerAdapter(childFragmentManager).apply {
                mCount = 1
                createFragment = { position ->
                    // 参数继续透传到下一个层级
                    EffectDetailsFragment.newInstance(mCurrentType, CommonConstant.EFFECTCENTER.ORDER_HOT).also {
                        currentFragments[0] = it
                    }
                }
            }
        }
    
        companion object {
            fun newInstance(type: Int): EffectListFragment {
                // 参数透传
                return EffectListFragment().apply {
                    arguments = Bundle().apply { putInt(CommonConstant.EFFECTCENTER.TYPE, type) }
                }
            }
        }
    

    最后接受并消费透传 type 的是 EffectDetailsFragment,即纵向滚动列表的承载页:

    class EffectDetailsFragment : BaseMvpFragment<EffectDetailsContract.IView, EffectDetailsPresenter>() {
        // 保存透传参数的变量
        private var mCurrentType: Int = 0
        override fun initConfig(savedInstanceState: Bundle?) {
            // 接受透传参数
            mCurrentType = arguments?.getInt(CommonConstant.EFFECTCENTER.TYPE) ?: 0
            mOrder = arguments?.getInt(ORDER) ?: 0
            initDetailsContent()
        }
    
        override fun initEvent() {
            mTvNetworkRetry.setOnClickListener(this)
            mEffectDetailsAdapter.addOnItemClickListener(object : EffectDetailsAdapter.OnItemClickListener {
                override fun onItemClick(entity: EffectDataEntity?) {
                    entity?.let {
                        // 消费透传参数,上报埋点
                        StudioReport.reportClickAlbumClick(it.type, it.id, mOrder, "all", mCurrentType)
                    }
                }
            })
        }
    
        companion object {
            const val ORDER: String = "order"
            fun newInstance(type: Int, order: Int): EffectDetailsFragment {
                return EffectDetailsFragment().apply {
                    // 接收透传
                    arguments = Bundle().apply {
                        putInt(CommonConstant.EFFECTCENTER.TYPE, type)
                        putInt(ORDER, order)
                    }
                }
            }
        }
    }
    

    整个界面层级以及参数传递路径如下:

    Activity 中有一个 Fragment,而它内部又嵌套了一个 Fragment。

    中间的 Fragment 很无辜,因为它并不需要消费 type 参数,而只是做一个快递员。

    当前只有两层,如果层级再增多,因此而增加的复杂度和工作量让人难以接受。

    向上查询

    如果把上述传参的方式叫做 “自顶向下透传” 的话,下面要介绍的这个方案可以称为 “自底向上查询”

    自顶向下透传是容易实现的,因为父亲总是持有孩子的引用,向孩子注入参数轻而易举。

    有没有一种方案可以实现反向的参数查询,即当孩子触发埋点事件时,逐级向上查询父亲生成的参数。

    Android 中的控件是持有父亲的:

    // android.view.View.java
    public final ViewParent getParent() {
        return mParent;
    }
    

    通过一个循环不停地获取当前控件的父控件,就能从 View 树的叶子结点遍历到树根:

    var viewParent: View?
    do {
        viewParent = viewParent?.parent as? View
    } while(viewParent != null)
    

    对于 Activity 来说,树根就是 DecorView。对于 Fragment 来说,树根就是 onCreateView() 中创建的视图。

    Fragment 最终会以一个 View 的形式嵌入到 Activity 的 View 树中。所以对于当个 Activity 来说,不管嵌套几层 Fragment,其视图结构最终都可以归为一棵 View 树。

    如何让 Activity View 树中的每一个控件都能携带业务参数?

    需要定义一个接口:

    // 可跟踪的结点
    interface TrackNode {
        fun fillTrackParams(): HashMap<String, String>?
    }
    

    为 View 新增一个扩展属性,让每个控件都持有一个 TrackNode:

    var View.trackNode: TrackNode?
        get() = this.getTag(R.id.spm_id_tag) as? TrackNode
        set(value) {
            this.setTag(R.id.spm_id_tag, value)
        }
    

    将携带参数的能力存放在 View.tag 中,这样任何控件都可以携带参数了。

    让 Activity 携带参数体现为让其根视图 DecorView 携带参数:

    // 在所有 Activity 的基类中实现 TrackNode,则所有 Activity 都具备了携带参数的能力
    open class BaseActivity : AppCompatActivity(), TrackNode{
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            // Activity 携带参数表现为其根视图携带参数
            window.decorView.rootView.trackNode = this
        }
    
        override fun fillTrackParams(): HashMap<String, String>? {
            return null
        }
    }
    

    同样地,Fragment 也有类似的实现:

    open class BaseFragment : Fragment(), TrackNode {
    
        override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
            super.onViewCreated(view, savedInstanceState)
            // Fragment 携带参数表现为其根视图携带参数
            getView()?.trackNode = this
        }
    
        override fun fillTrackParams(): HashMap<String, String>? {
            return null
        }
    }
    

    这样一来,一个窗口中整个 View 树的任何一个结点都具备了携带参数的能力,从最顶层的 Activity,到其内部的 Fragment,再到任何一个控件。参数就不再需要自顶向下透传,而是可以自底向上查询:

    fun View.getTrackNode(): HashMap<String, String> {
        val map = hashMapOf<String, String>()
        // 获取当前结点的参数
        trackNode?.fillTrackParams()?.also { map.putAll(it) }
        // 不断获取父亲以向上查询
        var viewParent = parent as? View
        do {
            // 查询父控件是否携带参数
            val info = viewParent?.trackNode?.fillTrackParams() 
            // 若父控件携带参数则将其拼接
            info?.also { map.putAll(it) }
            // 继续获取父控件
            viewParent = viewParent?.parent as? View
        } while (viewParent != null) // 直到回溯到了整个界面的根视图
        return map
    }
    

    为 View 自定义了一个扩展方法,该方法返回一个 Map,该 Map 中包含了从当前界面向上到树根整个链路中所有携带的参数集合。

    重构透传

    先在 Activity 层级将标签页的 type 拼接到 TrackNode 中,而不是作为参数传递给 EffectListFragment:

    // EffectActivity.kt
    override fun showEffectListContent(index: Int, from: String?) {
        mVpEffectContent.adapter = SimpleFragmentStatePagerAdapter(supportFragmentManager,BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT).apply {
            mTitles = arrayOf("视频库", "音乐", "音效", "贴纸", "转场", "特效", "滤镜", "背景", "字幕", "字体")
            mCount = mTitles!!.size
            createFragment = { position ->
                when (position) {
                    0 -> {
                        val fragment = RemoteCenterFragment.newInstance(-1,true,0,MaterialProtocol.SOURCE.MATERIAL_MARKET,1,from,0,0)
                        val paramsParser = DeepLinkParamsParser(compileDeepLinkParams())
                        (fragment as IDeepLinkPage).setDeepLinkParams(paramsParser.deeplinkParams)
                        fragment
                    }
                    1 -> MaterialMusicFragment.newInstance(from, mSelectedTabId, mSelectedModelId)
                    2 -> MaterialAudioFragment.newInstance(mSelectedTabId, mSelectedModelId)
                    // 可以无差别地构造 EffectListFragment 实例
                    else -> EffectListFragment.newInstance()
    
                }
            }
        }
        mVpEffectContent.currentItem = index
        mVpEffectContent.offscreenPageLimit = 10
        mTlEffectTabs.setupWithViewPager(mVpEffectContent)
    }
    // 索引和常量的映射
    private val tabMap = mapOf(
        3 to CommonConstant.SERVER.TYPE_VSTICKER,
        4 to CommonConstant.SERVER.TYPE_TRANSITION,
        5 to CommonConstant.SERVER.TYPE_EFFECT,
        6 to CommonConstant.SERVER.TYPE_FILTER,
        7 to CommonConstant.SERVER.TYPE_BACKGROUND,
        8 to CommonConstant.SERVER.TYPE_SUBTITLE,
        9 to CommonConstant.SERVER.TYPE_FONT,
    )
    
    // Activity 层级的参数拼接
    override fun fillTrackParams(): HashMap<String, String>? {
        // 只拼接当前显示页的常量
        return hashMapOf("type" to tabMap[mVpEffectContent.currentItem].toString()
    }
    

    第二个层级的 Fragment 在构建实例时不再接受传参(更加单纯):

    class EffectListFragment : BaseFragment() {
        companion object {
            // 没有参数传入的构造方法
            fun newInstance(): EffectListFragment = EffectListFragment()
        }
    }
    

    在最内层的 Fragment 消费参数:

    class EffectDetailsFragment : BaseFragment(){
        // 向上查参         
        private val type: Int
            get() = view?.getTrackNode()?.getOrElse("type") { "" }?.safeToInt() ?: 0          
        override fun initEvent() {
            mEffectDetailsAdapter.addOnItemClickListener(object : EffectDetailsAdapter.OnItemClickListener {
                override fun onItemClick(entity: EffectDataEntity?) {
                    // 消费参数进行埋点
                    entity?.let {
                         ReportUtil.reportClick(it.id, type)
                    }
                }
            })
        }
    
        companion object {
            // 没有type 传入的构造方法
            fun newInstance(): EffectDetailsFragment = EffectDetailsFragment()
        }
    }
    

    消费参数时不再通过上一个界面透传,而是通过自底向上的查询。

    因为约定的参数是 HashMap<String, String> 类型的,而消费的参数是 Int 类型的所以得进行类型转换

    如果强制的使用如下方式进行转换,则可能发生运行时崩溃,比如下面这个场景:

    " " as Int
    

    为了避免这类崩溃,有必要做一个统一处理:

    fun String?.safeToInt(): Int = this?.let {
        try {
            Integer.parseInt(this)
        } catch (e: NumberFormatException) {
            e.printStackTrace()
            0
        }
    } ?: 0
    

    为 String 定义一个扩展方法,该方法返回 Int 值,在内部调用Integer.parseInt(this)将当前的 String 转换为 Int,并在其外层包裹了 try-catch 以捕获非数字字串转换异常的情况。

    使用 Kotlin 中的预定义let()方法配合try-catch表达式以及 Evis 运算符,让这个方法的表达异常简洁。

    其中let()的定义如下:

    public inline fun <T, R> T.let(block: (T) -> R): R {
        contract {
            callsInPlace(block, InvocationKind.EXACTLY_ONCE)
        }
        return block(this)
    }
    

    let 也是一个扩展方法,被扩展对象是泛型,表示它可以被任何对象调用。let 接收一个 lambda,该 lambda 会将类型 T 变换为 类型 R,let 方法内部只是通过block(this)执行了该 lambda 并返回,遂 let 的返回值即是 lambda 的值(lambda 最后一条语句的值)。

    从 let 的定义可以看出,它通常用于将一个对象转换为另一个对象。当前场景中它被用于将 String 转换为 Int。

    String 转换为 Int 是可能抛异常的,遂用 try-catch 包裹之。Kotlin 中try-catch是一个表达式,它是有值的,等于每个分支最后一条语句的值。这个特性使得不必多声明一个局部变量:

    int result = 0;
    try {
        result = Integer.parseInt(str);
    } catch (Exception e) {
        result = -1
    }
    return result;
    

    所以整个 safeToInt() 的返回值是 let 的返回,而 let 的返回值是 try-catch 的返回值。

    最后因为被扩展的对象是 String?,所以返回值是可空的,方法内部通过?:处理了这种情况。表达式1 ?: 表达式2意思是当表达式1为空时,执行表达式2。

    适用场景

    自底向上查询参数方案适用于同一窗口的任何层级之间的参数传递

    当在 Fragment 中向上查询时,要在onCreateView()之后,因为在此之前,Fragment 的视图层级还未生成,getView()会返回 null。

    RecyclerView 中 ItemView 无法使用自底向上查询,因为ItemView.parent为空。

    可以在 inflate ItemView 布局时将 attachToRoot 设置为 true:

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val itemView = LayoutInflater.from(parent.context).inflate(
            R.layout.material_item_effect_details, 
            parent, 
            true)// 将 attachToRoot 设置为 true
        return ViewHolder(itemView)
    }
    

    这样 ItemView 的 parent 就不为空了,但是 ItemView 的 LayoutParam 就会被其父控件的 LayoutParam 覆盖,使得 ItemView 的布局样式不符合预期。

    列表项参数透传解决方案

    那列表相关的参数透传路径就一定得是 Activity -> Adapter -> ViewHolder ?

    Adapter 的语义是完成数据到视图的转换。ViewHolder 的语义是描述如何构建表项视图及其交互。

    如果将透传逻辑和表项的构建及交互逻辑耦合在一起,除了增加了透传参数的复杂度,还使得后者无法被独立复用。

    更好的做法是将表项的曝光和点击事件上移到 Activity/Fragment 处理,为此新增了两个扩展法方法:

    fun RecyclerView.setOnItemClickListener(listener: (View, Int, Float, Float) -> Boolean) {
        addOnItemTouchListener(object : RecyclerView.OnItemTouchListener {
            val gestureDetector = GestureDetector(context, object : GestureDetector.OnGestureListener {
                override fun onShowPress(e: MotionEvent?) {
                }
    
                override fun onSingleTapUp(e: MotionEvent?): Boolean {
                    e?.let {
                        findChildViewUnder(it.x, it.y)?.let { child ->
                            val realX = if (child.left >= 0) it.x - child.left else it.x
                            val realY = if (child.top >= 0) it.y - child.top else it.y
                            return listener( child, getChildAdapterPosition(child), realX, realY )
                        }
                    }
                    return false
                }
    
                override fun onDown(e: MotionEvent?): Boolean {
                    return false
                }
    
                override fun onFling( e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float ): Boolean {
                    return false
                }
    
                override fun onScroll( e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float ): Boolean {
                    return false
                }
    
                override fun onLongPress(e: MotionEvent?) {
                }
            })
    
            override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {
    
            }
    
            override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
                gestureDetector.onTouchEvent(e)
                return false
            }
    
            override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
            }
        })
    }
    

    通过判断触点坐标落在 RecyclerView 的哪个孩子上进行点击事件的回调。详细分析可以点击读源码长知识 | 更好的 RecyclerView 表项点击监听器

    以及 RecyclerView 表项百分比曝光扩展方法:

    fun RecyclerView.onItemVisibilityChange(percent: Float = 0.5f, block: (itemView: View, adapterIndex: Int, isVisible: Boolean) -> Unit) {
        val rect = Rect() // reuse rect object rather than recreate it everytime for a better performance
        val visibleAdapterIndexs = mutableSetOf<Int>()
        val scrollListener = object : OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)
                // iterate all children of RecyclerView to check whether it is visible
                for (i in 0 until childCount) {
                    val child = getChildAt(i)
                    val adapterIndex = getChildAdapterPosition(child)
                    val childVisibleRect = rect.also { child.getLocalVisibleRect(it) }
                    val visibleArea = childVisibleRect.let { it.height() * it.width() }
                    val realArea = child.width * child.height
                    if (visibleArea >= realArea * percent) {
                        if (visibleAdapterIndexs.add(adapterIndex)) {
                            block(child, adapterIndex, true)
                        }
                    } else {
                        if (adapterIndex in visibleAdapterIndexs) {
                            block(child, adapterIndex, false)
                            visibleAdapterIndexs.remove(adapterIndex)
                        }
                    }
                }
            }
        }
        addOnScrollListener(scrollListener)
        addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
            override fun onViewAttachedToWindow(v: View?) {
            }
    
            override fun onViewDetachedFromWindow(v: View?) {
                if (v == null || v !is RecyclerView) return
                if (ViewCompat.isAttachedToWindow(v)) {
                    v.removeOnScrollListener(scrollListener)
                }
                removeOnAttachStateChangeListener(this)
            }
        })
    }
    

    通过监听列表滚动事件,并在其中遍历列表所有的孩子,同时计算每个孩子矩形区域在列表中展示的百分比判断其可见性,详细分析可以点击

    总结

    通过思路的转变,将“自顶向下透传参数”转变为“自顶向上查询参数”,降低了同一界面层级中各控件之间的耦合,使得每个控件都更加单纯。

    推荐阅读

    业务代码参数透传满天飞?(一)

    业务代码参数透传满天飞?(二)

    全网最优雅安卓控件可见性检测

    全网最优雅安卓列表项可见性检测

    页面曝光难点分析及应对方案

    你的代码太啰嗦了 | 这么多对象名?

    你的代码太啰嗦了 | 这么多方法调用?

    作者:唐子玄
    链接:https://juejin.cn/post/7165427216589783076

    相关文章

      网友评论

          本文标题:业务代码参数透传满天飞?(二)

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