查看原文
其他

RecyclerView的曝光统计

狐友团队 赵丰 搜狐技术产品 2021-01-15


 

本文字数:6661

预计阅读时间:17分钟


 


本文提供了一种Android列表曝光统计的功能实现。使开发者无需关心数据收集过程,只需简单的设置即可在适当的曝光时机通知开发者所需的曝光数据。



产品需求中我们经常会有统计recyclerView的每个item的曝光需求:
  1. recyclerView上下滚动每个item从不可见进入到屏幕可见范围(这里包含item的可见范围,还有item的曝光时长)
  2. 在tab切换,或者页面切换的时候会引起recyclerView从不可见到可见的变化(当前屏幕上可见的item都算一次曝光)
  3. 数据变化引起的曝光
为了达到产品需求,我们首先需要数据收集,在滑动过程中收集所有需要上报的item,然后在适当的时机进行上报比如滑动停止、页面切换。
我们希望开发者在使用的时候,不需要自己关心数据收集,只需要注册了对应的曝光回调,就可以在适当的时机返回需要上报的item数据。

1 使用方法


//设置露出多少算曝光
myAdapter.setOutPercent(0.1f);
//设置显示多长时间算曝光
myAdapter.setExposureTime(0);
 
//设置曝光监听
myAdapter.setExposureFunc(items->{
    //返回需要曝光的数据列表
    for (ExpItem<NewFeedBean> item : items) {
        LogUtil.d("kami","exposure position = " + item.getPostion() + ": " +item.getData().sourceFeed.content + ",duration = " + (item.getEndTime() - item.getStartTime()));
    }
    return null;
},item->{
    //自定义需要曝光筛选:比如只曝光广告数据
    return item.getData().isAd();
});
这里我们会返回一个ExpItem列表即需要曝光的数据,开发者只需要在这里写对应的上报逻辑即可。
ExpItem包含数据如下:
class ExpItem<T> {
    //对应item的itemView
    var itemView: View? = null
    //所在位置
    var postion: Int = 0
    //对应item的关联数据
    var data: T? = null
    //开始曝光时间
    var startTime = 0L
    //结束曝光时间
    var endTime = 0L
}



2 解决方案


通过上面的需求分析,我们可以知道recyclerView的曝光主要分为滑动曝光和可见性变化曝光,还有数据变化曝光。
所以接下来我们也分成这三个部分去分别实现:
  •  1  滑动曝光 
  • 我们可以通过监听recyclerView的滑动过程,在滑动的过程中收集曝光的数据(因为曝光行为就是在滑动过程中产生的),然后当滑动停止的时候去进行一个曝光上报(这样既能保证实时性,又可以兼顾手机的性能)。


  •  2  可见性变化曝光 
  • 这里我们需要监听recyclerView的可见性变化,但是并没有提供给我们View可见性变化的监听。虽然有一些焦点变化的监听,但是并不能完全覆盖View的可见性变化。所以这里我们必须想别的办法来实现,这里我通过Actvity的生命周期的onResume和onPause想到有没有可能实现Fragment的onFragmentResume和onFragmentPause来监听Fragment的可见性,监听到Fragment的可见性,也就相当于监听到recyclerView的可见性。然后遍历当前可见的Item收集,并上报即可。


  •  3  数据变化引起的曝光 
  • 有时候数据变化也会引起相应的曝光,这种的我们比较好处理只需要监听相应的数据变化。然后对可见的需要曝光的item进行曝光处理即可。

接下来将从三个方面分析具体的实现原理。


3 实现原理


因为Adatper控制着RecyclerView的ViewHolder的创建和绑定,并且对应的数据适配都是在Adapter中完成的,所以这里选择重写Adapter来实现曝光功能。

3.1 recyclerView滑动过程中引起的曝光

首先我们需要在recyclerView的滑动过程中进行数据收集,即收集显示到屏幕上需要曝光的item。
  • 我们知道当recylerView的ViewHolder加载到屏幕上会先调用onViewAttachedToWindow(holder: VH),所以我们就选择在这个方法进行数据收集。只要显示到屏幕的数据都会被收集到collectDatas列表当中
    /**
     * 进行曝光items收集
     */

    override fun onViewAttachedToWindow(holder: VH) {
        val item = ExpItem<T>()
        item.data = holder.mData
        item.itemView = holder.itemView
        item.postion = holder.mPosition
        collectDatas.add(item)
        super.onViewAttachedToWindow(holder)
        //检查曝光范围,并更新曝光开始时间
        if (innerCheckExposureData(item)){
            item.startTime = TimeUtil.getCurrentTimeMillis()
        }
 
    }
  • 接着我们需要筛选需要进行曝光的数据,计算每个Item在屏幕上的位置,自定义筛选条件(比如:只曝光广告)这个筛选我们需要在recyclerView的滚动过程中进行计算,因为滚动过程中ViewHolder的露出范围是不断发生改变的,然后我们把筛选的数据从collectDatas中移动到expDatas列表当中


  • 1.为什么这个筛选过程要放在onScrolled过程中?

首先曝光的行为是在滑动过程中达成的,比如我们不断的上下滑动recyclerView,导致item_1不断的出现在屏幕中和消失在屏幕中。假如这个过程中item_1曝光了5次,滑动停止后回到我们初始的滑动位置。如果我们在滑动停止的时候来筛选曝光的item,可能会认为完全没有新曝光的item。因为我们滑动停止在了原来初始的位置,显然这个计算是不对的。想要正确的记录曝光的item,就必须要在recyclerView的滑动过程中去筛选达到曝光条件的item。
其次考虑到曝光时间,在滑动过程中Item达到曝光条件,这时候我们就应该记录曝光的开始时间。在其他的时机无法正确的记录曝光时间。

  • 2.在onScrolled的过程中进行筛选计算是否会影响recyclerView的性能,导致滑动不流畅?
这个筛选计算分为两个部分,一个部分是需要开发者定义的筛选逻辑,这里就需要开发者自己注意不要有耗时的判断逻辑。 第二个部分是内部的筛选逻辑,主要是判断item的露出高度是否达到曝光要求。
这个判断是否会影响recyclerView的性能?其实也是不会的。
首先item的自身的高度、位置(滑动偏移量)在滑动过程中每一帧的渲染之前都是已经由recyclerView计算好的,否则recyclerView也没有办法把每个item绘制在正确的位置。所以显然这个计算肯定不会影响recyclerView的流畅性。
而我们需要做的判断主要是拿到当前item的位置信息,进行比较看是否达到曝光要求,这个显然也不是一个耗时操作。我们是通过 itemView!!.getGlobalVisibleRect(rect)这个方法来获取item的位置信息的,通过代码跟踪,我们可以看下具体实现逻辑
public boolean getChildVisibleRect(
View child, Rect r, android.graphics.Point offset, boolean forceParentCheck) 
{
        ...
        rect.set(r);
        final int dx = child.mLeft - mScrollX;
        final int dy = child.mTop - mScrollY;
        ...
        rect.offset(dx, dy);
  ...
  rectIsVisible = rect.intersect(00, width, height);
  ...
  return rectIsVisible;
}

public boolean intersect(float left, float top, float right, float bottom) {
        if (this.left < right && left < this.right
                && this.top < bottom && top < this.bottom) {
            if (this.left < left) {
                this.left = left;
            }
            if (this.top < top) {
                this.top = top;
            }
            if (this.right > right) {
                this.right = right;
            }
            if (this.bottom > bottom) {
                this.bottom = bottom;
            }
            return true;
        }
        return false;
    }
通过代码可知我们获取itemView的可见范围主要是通过itemView的当前位置rect和他所在的ViewGroup(即recyclerView)的范围大小做交集。看代码可知也都是一些比较大小的逻辑,没有耗时操作,所以无需担心这个会造成recyclerView的滑动卡顿。
那么接下来我们看下具体实现筛选过程
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)
                val it = collectDatas.iterator()
                while (it.hasNext()) {
                    val item = it.next()
                    //判断露出范围
                    if (innerCheckExposureData(item)) {
                        if (item.startTime == 0L) {
                            item.startTime = TimeUtil.getCurrentTimeMillis()
                        }
                        if (funcCheck == null) {
                                expDatas.add(item)
                            //自定义过滤条件
                            } else if (funcCheck!!.invoke(item)) {
                                expDatas.add(item)
                            }
//                        LogUtil.d("kami", "scroll remove collectDatas to expDatas  = " + item.postion)
                        it.remove()
                    }
                }
            }


onScrolled方法第8-10行是更新曝光开始时间 onScrolled方法第7行是露出范围的检测
  /**
    * 内部判断当itemView的可见度达到多少才需要曝光
    */

   private fun innerCheckExposureData(item: ExpItem<*>): Boolean {
       val rect = Rect()
       val visible = item.itemView!!.getGlobalVisibleRect(rect)
       if (visible) {
           if (rect.height() >= item.itemView!!.measuredHeight * outPercent) {
               return true
           }
       }
       return false
   }


onScrolled方法第11-16行是我们自定义的筛选条件判断,即下面对应第10行的筛选条件
/**
  * 设置曝光监听
  */

myAdapter.setExposureFunc(items->{
    //返回需要曝光的数据列表
    for (ExpItem<NewFeedBean> item : items) {
        LogUtil.d("kami","exposure position = " + item.getPostion() + ": " +item.getData().sourceFeed.content + ",duration = " + (item.getEndTime() - item.getStartTime()));
    }
    return null;
},item->{
    //自定义需要曝光筛选:比如只曝光广告数据
    return item.getData().isAd();
});


  • 最后在滑动停止进行曝光数据回调之前进行曝光时长的筛选。从expDatas中选择达到曝光时长的数据,最后进行数据上报
//设置曝光监听
when (newState) {
 //滑动完成
 RecyclerView.SCROLL_STATE_IDLE ->
    val needExpDatas = getExposureList(expDatas)
     if (!needExpDatas.isEmpty()) {
         funcExp?.invoke(needExpDatas)
     }
 else -> {
 }


第5行就是我们对曝光时长的筛选
  /**
   * 内部判断当itemView的曝光时长达到多少才需要进行曝光
   */

  private fun getExposureList(expDatas: ArrayList<ExpItem<T>>): ArrayList<ExpItem<T>> {
      val needExpDatas = ArrayList<ExpItem<T>>()
      val it = expDatas.iterator()
      while (it.hasNext()) {
          val item = it.next()
          if (item.endTime != 0L) {
              if (item.endTime - item.startTime >= exposureTime) {
                  needExpDatas.add(item)
              }
              it.remove()
          } else {
              if (TimeUtil.getCurrentTimeMillis() - item.startTime >= exposureTime) {
                  item.endTime = TimeUtil.getCurrentTimeMillis()
                  needExpDatas.add(item)
                  it.remove()
              }
          }
      }
      return needExpDatas
  }
在ViewHolder AttachToWindow 的时候,和RecyclerView 滑动的时候我们会更新曝光开始时间,在 ViewHolder DettachToWindw 和 RecyclerView 滑动停止的时候我们会更新曝光结束时间。
item.endTime 不为零如果达到曝光时长,则表示需要进行曝光加入曝光列表,否则就舍弃。
item.endTime 为零则表示ViewHoler还在持续曝光,则用当前时间计算,达到曝光时间加入曝光列表,否则不做处理(因为还在持续曝光,等下次滑动达到曝光时长在进行曝光)。
下面是onViewDetachedFromWindow的具体代码,包括移除无需曝光的数据和更新曝光结束时间。
    /**
     * 对移除的itemView进行曝光时长的修改
     */

    override fun onViewDetachedFromWindow(holder: VH) {
//        在Dettached的时候,未被移动到expDatas列表的数据证明没有达到曝光条件,无需曝光。就可以把它们从collectDatas列表中移除了
        val it = collectDatas.iterator()
        while (it.hasNext()) {
            val item = it.next()
            if (holder .mPosition == item.postion && holder.itemView === item.itemView) {
                it.remove()
            }
        }
//       更新曝光结束时间
        for (expItem in expDatas) {
                if (holder.mPosition == expItem.postion && expItem.itemView === holder.itemView) {
                    expItem.endTime = TimeUtil.getCurrentTimeMillis()

                }
        }
        super.onViewDetachedFromWindow(holder)
    }

具体过程如下图所示:

这样我们就完成了RecyclerView在滑动过程中引起的上报。


3.2 切换页面,reyclerView从不可见到可见的曝光

上面我们完成了RecyclerView在滑动过程中的上报,接下来需要实现页面切换引起的曝光。 第一我们需要实现recyclerView从可见到不可见的变化监听,第二在RecyclerView从不可见到可见的时候,我们遍历recyclerView当前在屏幕中的ViewHoler进行曝光处理即可。

遍历RecyclerView获取可见ViewHolder我们很好实现,但是监听RecyclerView的可见性变化却是不怎么好实现的。

Activity跳转引起的页面切换,我们可以监听Activity的生命周期来监听,页面的可见性发生变化。

但是若是Fragment之间的tab切换,引起的可见性变化却是不好监听的。

我也尝试过用ViewTreeObserver.onWindowFocusChangeListener来监听,但是也只是在Activity跳转的时候才会引起的焦点发生变化,和监听Activity生命周期是一个效果。

所以这里我们必须要实现一个Fragment可见性发生变化的监听,这样在Fragment当中的RecyclerView就可以通过Fragment知道自己的可见性变化。


🔺3.2.1 Fragment可见性变化的监听 

我们知道Activity从不可见到可见会回调 onResume ,从可见到不可见会回调onPause。而Fragment的生命周期是跟随Activity的,但是onResume和onPause并不能表示Fragment的完全表示可见性的变化。

我们判断Fragment的可见性发生了变化,比如tab切换 通过FragmentManager.show(fragment)去显示。这时候会回调onHiddenChanged(boolean hidden)表示Fragment的可见性发生变化。

还有 Fragment+ViewPage的方式,我们切换页面。但是并没有回调onHiddenChanged(boolean hidden)。这时候我们是通过setUserVisibleHint(boolean isVisibleToUser)的回调来判断Fragment的可见性发生变化。

:Fragment的可见性是通过

  • 1.跟随Activity: onResume +onPause
  • 2.被FragmentManger控制:onHiddenChanged(boolean hidden)
  • 3.Fragment+ViewPager: setUserVisibleHint(boolean isVisibleToUser) 
  • 注:2和3是互斥的不会同时出现。 但是这样我们想要监听Fragment的可见性变化就需要根据具体的业务情况。各自处理,尤其是当Fragment的出现深层的父Fragment套子Fragment,这样处理起来就比较麻烦。比如下面的情况:

假如Activity回调了onResume但是界面上可见的只有Fragment A 和 他的 子Fragment A1,但是实际情况是所有的Fragment都回调了onResume,但是我们通常的业务处理只需要处理可见的Fragment就行了。 所以我们还要根据具体情况通过onHiddenChanged(boolean hidden)和setUserVisibleHint(boolean isVisibleToUser)来判断Fragment是否可见。

再比如下面的情况:
假如我们从Fragment A 切换到 Fragment B
Fragment A,A1 从可见到不可见 ; FragmentB,B1从 不可见到可见
A和B都可以通过onHiddenChanged(boolean hidden)来判断可见性的变化,但是A1和B1并不会 有任何的回调通知它们可见性发生了变化。他们是因为自己的父Fragment的可见性发生了变化,所以引起的自身的可见性的变化,所以原来的监听方法根本就不起作用了。
所以我想我们是否可以实现一个onFragmentResume的方法只有在Fragment真正从不可见到可见的时候才会回调,onFragmentPause 在Fragment从 可见到不可见的时候回调。类似于Activity的onResume和onPause。我们只要在BaseFragment中实现这两个方法,那么子Fragment 就可以通过这两个方法来监听 Fragment的可见性的变化。
首先我们在BaseFragment中增加两个变量,一个是isVisible代表当前Fragment是否可见,另一个是fakeVisible这个是由系统方法告诉我们当前Fragment是否可见,isVisible很好理解这里就不再解释。
fakeVisile在Fragment是通过FragmentManger加载的时候 fakeVisible = !isHidden();Fragment通过ViewPage+Fragment的方式加载的时候 fakeVisible = getUserVisibleHint();但是fakeVisible为true的时候Fragment并不一定可见,因为它的父Fragment可能不可见,所以即便判断它的fakeVisible为true,它也不可见。
这样我们就发现了,如果Fragment无父Fragment的时候,那么fakeVisible就可以真实的反应它可见性。如果Fragment有父Fragment的时候,当它的父Fragment可见,那么fakeVisible也可以真实的反应它的可见性,若它的父Fragment不可见,则该Fragment也不可见。
所以子Fragment的可见性变化跟随父Fragment。即在父fragment的可见性发生变化的时候,可能会引起子Fragment的变化。
父Fragment子Fragment
可见→ 不可见可见→ 不可见 ;
不可见 → 不可见
不可见 → 可见不可见(部分 A1)→ 可见;
不可见(部分 A2) → 不可见


根据上面的理论,我们首先处理Fragment的onRsume
@Override
  public void onResume() {
      super.onResume();
      if (fromVisibleHint) {
          fakeVisible = getUserVisibleHint();
      }else{
          fakeVisible = !isHidden();
      }
      //当有父Fragment的时候,会跟随父Fragment的可见性变化,所以无需处理
      if (getParentFragment() != null && getParentFragment() instanceof BaseFragment) {
              return;
      }
 
      if (fakeVisible) {
          isVisible = true;
          onFragmentResume(true);
      }
  }

Fragment的onResume是跟随Actvity的onResume的,所以同样会引起Fragment可见性发生变化。我们首先获取当前Fragment的fakeVisible,然后判断假如该Fragment有父Fragment,那么直接return不做处理,因为对于子Fragment我们要让他跟随父Fragment的可见性变化。假如该Fragment没有父Fragment,我们认为fakeVisible所表达的可见性是正确的。只有真正可见的Fragment我们设置isVisible = true ,然后回调onFragmentResume。


那么有人要问了,假如这个Fragment有子的Fragment呢?

它的子Fragment如何处理?接着看下面代码:

/**
    * Fragment可见状态恢复
    *
    * @param isActivityResume 是否是跟随Activity的OnResume行为
    */

   @CallSuper
   public void onFragmentResume(boolean isActivityResume) {
       if (!isAdded()) {
           return;
       }
 
       FragmentManager manager = getChildFragmentManager();
 
       List<Fragment> fragments = manager.getFragments();
       if (fragments != null) {
           for (Fragment childFragment : fragments) {
               if (childFragment != null && childFragment instanceof BaseFragment) {
                   if (((BaseFragment) childFragment).fakeVisible) {
                       ((BaseFragment) childFragment).isVisible = true;
                       ((BaseFragment) childFragment).onFragmentResume(isActivityResume);
                   }
               }
           }
       }
   }
在onFragmentResume的时候,我们遍历它的子Fragment,因为onFragmentResume的时候该Fragment必定是可见的,所以子Fragment的fakeVisible就可以正确的表达他的可见性。所以我们对可见的子Fragment也设置它的isVisible = true,并且回调onFragmentResume。
这样我们只实现了在Activity onResume的时候,真正可见的Fragment会调用onFragmentResume。
那么如上面的例子从FragmentA 切换到FragmentB的时候该如何处理呢?
@Override
 public void onHiddenChanged(boolean hidden) {
     super.onHiddenChanged(hidden);
    onTabChanged(!hidden);
 }
  
@Override
 public void setUserVisibleHint(boolean isVisibleToUser) {
     super.setUserVisibleHint(isVisibleToUser);
     fromVisibleHint = true;
     onTabChanged(isVisibleToUser);
 }
 
 private void onTabChanged(boolean visible){
     fakeVisible = visible;
     boolean curVisible = fakeVisible;
     if (getParentFragment() != null && getParentFragment() instanceof BaseFragment){
         BaseFragment  parent = (BaseFragment)getParentFragment();
         if (parent.isVisible = false) {
             curVisible = false;
         }
     }
     if (curVisible && !isVisible) {
         isVisible = true;
         onFragmentResume(false);
     } else if(!curVisible && isVisible){
         isVisible = false;
         onFragmentPause(false);
     }
 }
在tab切换的时候,会根据Fragment的加载方式不同回调onHiddenChanged或者setUserVisibleHint。在这两个方法中我们会更新fakeVisible。代码17-22行,我们判断该Fragment的父Fragment是否可见,并以此来判断当前Fragment真实的可见性;代码23-28行,我们会根据该Fragment的可见性变化调用onFragmentResume或者onFragmentPause。
这样我们就做到了,Fragment从不可见到可见就会调用onFragmentResume方法。
同理我们实现Fragment从可见到不可见都会调用onFragmentPause的方法,只需要在onPause和onFragmentPause进行相似的处理即可,这里就不再赘述。

🔺3.2.2 实现recycleView页面切换曝光

有了onFragmentResume和onFragmentPause这两个方法之后我们就可以像监听Activity生命周期那样,也监听Fragment的可见性变化了。这里为了方便使用我封装了一个LifecycleUtil,用来监听Activity的生命周期,或者Fragment的可见性。(具体的实现方式类似于Lifecyle的实现方式,这里就不再详细讲了)使用如下:
 private fun registLifecycle(){
        //监听 fragment 或者 activity的生命周期,对可见item进行曝光
        if (fragment!=null) {
            LifecycleUtil.bindLifecycle(fragment,{
                if (it == Constants.ActivityEvent.RESUME) {
                    exposureVisible()
                }
            })
        }else if(mContext is FragmentActivity) {
            LifecycleUtil.bindObserver(mContext as FragmentActivity,{
                if (it == Constants.ActivityEvent.RESUME) {
                    exposureVisible()
                }
            })
        }
    }

在接收到Constants.ActivityEvent.RESUME的时候,Fragment即从不可见变为可见,Actvity也是一样的,然后我们只需要遍历当前可见的ViewHolder进行曝光处理即可
 /**
     * 曝光可见items
     */

    private fun exposureVisible(){
         val needExpDatas = getVisibleExposureList()
                        if (!needExpDatas.isEmpty()) {
                            funcExp?.invoke(needExpDatas)
                        }

    }


下面是具体的遍历方法,同样会检测item的可见范围,和自定义曝光筛选,这里没有筛选曝光时长(产品需求)
  /**
   * 获取当前可见itemViews的曝光列表
   */

  private fun getVisibleExposureList(): ArrayList<ExpItem<T>>{
      var exposureList = arrayListOf<ExpItem<T>>()
      for(index in 0..mRecyclerView.childCount-1){
         var itemView = mRecyclerView.getChildAt(index)
          itemView?.let {
 
            var vh =  mRecyclerView.getChildViewHolder(it)
              if (vh is HyBaseViewHolder<*>) {
                  val item = ExpItem<T>()
                  item.data = vh.mData as T
                  item.itemView = vh.itemView
                  item.postion = vh.mPosition
                  if (innerCheckExposureData(item)) 
{
                      if (funcCheck==null) {
                          exposureList.add(item)
                      }else if (funcCheck!!.invoke(item)) {
                          exposureList.add(item)
                      }
 
                      if (collectDatas.contains(item)) {
                          collectDatas.remove(item)
                      }
 
                  }
              }
 
          }
      }
 
      return exposureList
  }
注意第23-24行代码这里我会把需要上报的数据从collectDatas中移除,是为了防止重复曝光上报。

3.3 RecylerView数据改变引起的曝光

实际情况中,我们还会遇到RecylerView的数据刷新,或者动态插入数据的特殊情况。假如这种情况也需要实时的进行数据上报的话我们可以进行数据变化的监听来实现。 比如:
  registerAdapterDataObserver(object :RecyclerView.AdapterDataObserver(){
            override fun onChanged() {
                super.onChanged()
                exposureVisible()
            }

            override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
                super.onItemRangeInserted(positionStart, itemCount)
                val needExpDatas = getVisibleExposureList(position,itemCount)
                if (!needExpDatas.isEmpty()) {
                    funcExp?.invoke(needExpDatas)
                }
            }

        })
在onChanged的时候,代表我们进行了数据刷新。这时候我们可以对当前可见item进行上报,当onItemRangeInserted的时候代表我们进行了数据插入,这时候我们可以对插入的数据可可见item进行上报。



4 总结

Fragment的可见性的监听,解决了子Fragment无法监听可见性变化问题,而且简化了开发者代码逻辑,在我们的很多业务逻辑也都是可以用到的,因为大部分业务逻辑我们是只关心当前可见的Fragment。
RecyclerView的曝光满足了大部分列表曝光上报需求,使用起来也是极为简单的,极大的节省了开发成本。




也许你还想看

(▼点击文章标题或封面查看)



安卓自定义view中绘画几何图形和文字及圆角ImageView图片等api使用及举例 2020-07-02
ELK日常使用基础篇 2020-06-18
iOS 隐形水印之 LSB 实现 2020-06-11
积木法搭建 iOS 应用—— VIPER 2020-06-04
Android死锁初探 2020-05-21




加入搜狐技术作者天团

千元稿费等你来!

戳这里!☛







    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存