自定义 LayoutManager,让 RecyclerView 效果起飞

·  阅读 13474

1.背景

bfa811970c074319961170cc6200d521_tplv-k3u1fbpfcp-zoom-1.gif

RecyclerView可以说是每个Android开发者都不可避免的一个话题,它既可以实现普通的水平/垂直列表,还可以用极少的代码实现瀑布流效果,并且搭配谷歌提供的各种工具类,还可以在此基础上实现多种自定义的动画效果、滑动效果等。当你看到上面的列表效果时,第一反应是用RecyclerView的什么工具来实现效果?比如尝试一下自定义LayoutManager。

不过在自定义LayoutManager前,还需要先做一些准备工作才能实现一个既满足展示需求也满足性能需求的LayoutManager,比如搞清楚LayoutManager对RecyclerView来说意味着什么,LayoutManager在RecyclerView中负责着哪些重要的工作,它在工作时又需要和RecyclerView中的哪些其它帮手共同合作,大家常说的RecyclerView缓存和View复用又是谁负责的?接下来我们到RecyclerView的源码中尝试寻找这些问题的答案。

本文内容主要是介绍自定义LayoutManager前需要了解的一些的前置工作,包括RecyclerView的整体设计及主要成员和作用,并总结了这些类与LayoutManager之间的联系,为后续自定义LayoutManager提供理论基础。

2. RecyclerView的整体架构设计

RecyclerView类的注释中对自己的解释:它是一种可以在有限区域内展示大量数据的View。不妨先根据这句简介盲猜一波RecyclerView可能拥有或需要拥有的一些能力:

  • RecyclerView的本质是ViewGroup,因此它需要将大量数据对象映射成View对象集合,将View对象集合中的View作为自己的子View在自身区域内布局展示。
  • 由于待展示的数据集可能是无限的,所以可能存在一个无限大的View对象集合,但RecyclerView自身区域有限,无法同时在这个区域内展示出所有子View,这意味着它需要提供按需在自身区域内布局出应被展示的子View的能力,并通过手势操作替换正在展示的子View的能力,相对应的,在替换展示的View时需要及时回收已经不被展示的View对象。
  • 如果数据集合中的大多数据,都可以使用同一种View形态展示出来,则意味着对每一个数据对象都创建一个对应的View是浪费的,如果用type来区分数据对象期望的View展示形态,RecyclerView需要拥有根据type安排数据对象复用View对象的能力,即同一种type的不同数据对象可以在不同时机复用同一个View对象进行展示。

对上述能力,RecyclerView安排不同的类进行处理,依次分别对应:

  • Adapter
  • LayoutManager
  • Recycler

这三个类对RecyclerView来说是必不可少的,它们是RecyclerView可以正常工作的基础。不过在实际使用过程中,Recycler的工作被封装在RecyclerView内部完成,对使用者来说是透明的。同时谷歌提供了几种LayoutManager的具体实现类,实现单列列表、瀑布流列表等效果,大多时候这几种LayoutManager足够满足需求。使用者在日常使用时更多地是与Adapter打交道,通过为RecyclerView设置自定义的Adapter提供期望展示的子View样式和数据绑定方式。

3.RecyclerView的重要成员

3.1 ViewHolder

在介绍第二节中提到的三个类之前,先了解RecyclerView中的基本单位:ViewHolder。如果用Item表示数据集中的一个数据对象,ItemView表示用来展示该Item的View对象。ViewHolder负责在RecyclerView中承载一个ItemView,除了维护View对象本身外,还维护着Item位置(position,此处的位置是指Item在数据集中的次序)、item类型、item Id等。大部分时候,RecyclerView内部或其辅助类并不会直接操作View,而是对ViewHolder进行操作。

在阅读RecyclerView源码时发现,一些操作需要用View做参数,也有一些操作需要用ViewHolder做参数,实际上在RecyclerView中,可以通过任意一样拿到另一样,不必太过纠结RecyclerView的各个内部类的变量中究竟维护的是哪种类型。

但一般认为,Adapter负责根据数据Item创建对应的ViewHolder;Recycler负责管理ViewHolder,根据实际情况创建新的ViewHolder或复用已有的ViewHolder;LayoutManager可以通过Recycler直接获取到View,负责将其添加到RecyclerView的布局中,并通过Recycler回收已经不被展示的View。

3.2 Adapter

Adapter负责将数据对象映射为View对象。待展示的数据集维护在Adapter内,Adapter除了负责将数据映射为View外,也会向外分发数据集中数据的变化。

将数据对象映射为可用来展示的View对象,在RecyclerView体系中被拆分为两步:

  • 步骤1:根据itemType创建符合预期的ViewHolder对象,此处更关注于View的结构样式。
  • 步骤2:根据position从Adapter维护的数据集中获取数据对象,将数据对象与ViewHolder中的View进行绑定,此处更关注View展示出的数据内容。

在代码实现中,RecyclerView.Adapter基类有两个待使用者实现的回调方法,这两个方法会分别被Adapter.createViewHolder和Adapter.bindViewHolder调用,对应着步骤1与步骤2。

public abstract VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType);

public abstract void onBindViewHolder(@NonNull VH holder, int position);
复制代码

现在Adapter已经拥有了将数据对象映射为View对象的能力,那么它是在什么时机执行这个能力的?将目光移到下一部分的内容:Recycler。

3.3 Recycler

Recycler的谷歌翻译是:回收商。正如字面含义,Recyler负责管理ViewHolder,它可以回收起已经不被展示的ViewHolder,并在恰当的时候复用这些ViewHolder。它最重要的一个能力就是根据position提供一个ViewHolder/View,使用者无需关心这个ViewHolder是新创建的还是复用已有的,Recycler帮助RecyclerView处理ViewHolder的缓存。

关于RecyclerView中有几级缓存,大家定义不同看法不一,一些人认为是四级缓存,包括一级屏幕内缓存、两级屏幕外缓存和一级自定义缓存;还有人认为RecyclerView是三级缓存,其中屏幕内缓存与一级屏幕外缓存合为一级,和另外一级屏幕外缓存与自定义缓存共同构成三级缓存;还有一些人也认为是三级缓存,但这三级缓存中不包括屏幕内缓存,而是两级屏幕外缓存和一级自定义缓存。本文介绍的是最后一种观点。

在了解Recycler处理缓存的机制前,会先介绍View被添加到ViewGroup后的几种状态变化,再介绍Recycler中对View两种不同的处理方法,最后介绍RecyclerView的缓存机制。

3.3.1 View的detach vs remove

在了解Recycler对View的两种处理方式前,我们先看一下View被添加到父View后其状态的流转。View被add到parent后,除了可以被remove外,还有一个更轻量级的detach操作,detached表示一种临时状态,意味着这个View在之后会马上被重新attach或彻底remove。如果一个View处于detached状态,像被remove一样它也无法通过其parent的getChildAt方法获得。

3.3.2 Recycle的scrap vs recycle

相对应的,Recycler对ViewHolder也有两种处理方式:scrap和recycle。

scrap通常和detach操作共同使用,如果使用Recycler对一个View进行scrap操作,表示期望该View已经处于detach状态(而不是removed状态),持有这个View的ViewHolder会被标记为scrap状态,然后临时存放到Recycler.mAttachedScrap列表中,等待进一步的处理(unScrap或recycle)。scrap是一种临时操作,通常表示该View之前在屏幕中展示,并且之后大概率也会继续展示,不希望被remove回收掉。mAttachedScrap是一个ArrayList,存放着没有被remove的子View的ViewHolder。

recycle通常和remove操作共同使用,如果使用Recycler对一个View进行recyle操作,表示期望该View已经从其parent中remove掉,并且持有该View的ViewHolder是unScrap状态。当ViewHolder及其View的状态都满足条件后,RecyclerView会将这个ViewHolder放入Recycler的缓存池中。recycle操作只针对已经被remove掉的View,它之前是被展示在屏幕中的,但由于滑动操作或数据集改变等因素,该View不再继续展示,此时它可以被回收起来等待复用。这也是本文认为RecyclerView是3级缓存的原因,只有被remove掉的View才有机会被回收缓存。

RecyclerView.Recycler的源码中,有一些方法或变量的命名也与scrap有关,但观察其使用,实际上都在做recycle的工作。

3.3.3 缓存与回收

了解detach/remove和scrap/recycle的区别后,RecyclerView的缓存机制变得更易读一些,缓存实际上是Recycler中存放ViewHolder的集合的变量,Recycler中用来表示三级缓存的变量的优先级从高到低分别为:mCacheViews、mViewCacheExtension和mRecyclerPool。其中mViewCacheExtension是自定义缓存,本文不做展开,只看mCacheView和mRecyclerPool,首先需要明确的是,这两者缓存的内容都是已经不在屏幕内展示的ViewHolder。

mCacheViews是更高效的缓存,既不需要创建ViewHolder步骤,也不需要重新绑定ViewHolder步骤,这意味着只有在数据对象完全匹配的时候,即待展示的数据Item与缓存的ViewHolder中维护的数据Item完全匹配时(ItemType与Item都相同),才会复用mCacheViews中的ViewHolder。

mRecyclerPool中缓存的ViewHolder对象的使用条件,相较于mCacheViews要求更低,只需ItemType匹配,即可复用ViewHolder,但使用时需要重新绑定ViewHolder。

简单介绍mCacheViews和mRecyclerPool数据结构上的区别。mCacheViews是一个ArrayList,可以存放ViewHolder类型的对象,mRecyclerPool是RecycledViewPool对象,此处先简单理解成一种Map<Int, ArrayList>类型的数据结构(实际上RecyclerView中并不是用Map实现的),Int表示itemType,ArrayList用来存放该itemType的ViewHolder对象。

Recycler回收ViewHolder的规则为:

  • 如果mCacheViews.size未达到最大size,则将该ViewHolder对象add到mCacheViews中;如果size已经达到最大值,则移除mCacheViews中最先被add的ViewHolder,再将待回收的ViewHolder添加到mCacheViews中。
  • 如果mCacheViews.size已经达到最大size,将最先被add到mCacheViews的ViewHolder对象从mCacheViews移除后,尝试将其回收到mRecyclerPool中,无论该ViewHolder是否成功回收到mRecyclerPool中,都会将这个ViewHolder对象从mCacheViews中移除。
  • 如果mRecyclerPool中可以存放这个ViewHolder的itemType的List的size(默认为5)已经达到最大值,则直接抛掉该ViewHolder对象,否则add到这个List中。

3.3.4 Recycler获取View

最后介绍Recycler获取ViewHolder的过程,Recycler可以根据一个给定的position获得一个可以直接用来展示的ViewHolder。在3.2中指出,Adapter将数据对象映射为View对象分成了两步进行,即创建View和绑定数据,Recycler从自己内部不同的地方获取ViewHolder,调用Adapter的步骤也略有区别。

  • Recycler先尝试从mAttachedScrap中获取可用的ViewHolder(可以认为该ViewHolder在复用前与复用后对应着同一个Item数据对象,且这个数据对象无变化),这里获取到的ViewHolder可以直接使用,既不需要执行Adapter.createViewHolder,也不需要执行Adapter.bindViewHolder。
  • 如果未从mAttachedScrap中取到可用的ViewHolder,Recycler会尝试去缓存中获取,本文省略自定义缓存一层的介绍,Recycler会先从mCacheViews中尝试获取到符合要求的ViewHolder对象,与从mAttachedScrap中获取到的ViewHolder相似,该ViewHolder可以直接使用。
  • 如果mCacheViews中依然没有满足条件的ViewHolder,则尝试从mRecyclerPool中获取到符合要求的ViewHolder,这里获得的ViewHolder itemType可以匹配,即View的结构样式满足需求,但需要重新进行数据绑定,即不需要执行Adapter.createViewHolder,但需要执行Adapter.bindViewHolder。
  • 如果Recycler没有从缓存中得到符合要求的ViewHolder,会完整的执行Adapter的两个步骤。

3.3.5 Recycler小结

此时我们已经大致了解怎样通过position(此处的position依然指代一个数据对象在Adapter维护的数据集中的次序,换句话说,我们可以用position表示一个特定的数据对象)来获得一个可用的ViewHolder,并且也清楚Recycler拥有两种操作View/ViewHolder的能力:scrap和recycle,来临时保存或缓存一些ViewHolder。那么是谁,在什么时候希望获取到可用来展示的ViewHolder?又是谁在什么时机会调用Recycler临时保存或回收ViewHolder?将目光放到RecyclerView必不可少成员的最后一员:LayoutManager。

3.4 LayoutManager

LayoutManager是RecyclerView中实际决定ItemView摆放规则与滑动规则的执行者,甚至可以决定ItemView的一些布局参数。LayoutManager中有几个待实现的抽象方法和空方法,给使用者充分的自由通过扩展LayoutManager实现自己想要的列表效果或滚动效果, 关于LayoutManager的具体工作在第四节详细介绍。

// 创建ItemView默认的LayoutParams
public abstract LayoutParams generateDefaultLayoutParams();
// 布局RecyclerView的子View
public void onLayoutChildren(Recycler recycler, State state) {
    Log.e(TAG, "You must override onLayoutChildren(Recycler recycler, State state) ");
}
// RecyclerView是否支持水平滑动
public boolean canScrollHorizontally() {
    return false;
}
// RecyclerView是否支持垂直滑动
public boolean canScrollVertically() {
    return false;
}
// 处理RecyclerView的水平滑动
public int scrollHorizontallyBy(int dx, Recycler recycler, State state) {
    return 0;
}
// 处理RecyclerView的垂直滑动
public int scrollVerticallyBy(int dy, Recycler recycler, State state) {
    return 0;
}
复制代码

3.5 ItemDecoration

除了上述提及的几个对于RecyclerView来说必备的辅助类外, RecyclerView还提供了一些可以在基本功能外达到更佳实践效果的类。本文特别摘出ItemDecoration做介绍,这个类是我们在日常使用RecyclerView时较常使用的类之一,并且它与LayoutManager处理布局有一定的关系。

ItemDecoration可以让使用者向ItemView添加特殊的绘制和布局偏移。 此处先不对绘制进行展开,着重介绍布局偏移。ItemDecoration可以通过重写getItemOffsets方法自定义ItemView的间距,getItemOffsets方法中使用的Rect记录四个值,这四个值类似于ItemView的padding或margin的概念,分别对应left、right、top、bottom。该方法会在LayoutManager measure ItemView时调用,并将值对应的添加到ItemView measure后的宽高结果中。

需要注意的是:为RecyclerView设置ItemDecoration的方法是add而不是set,在RecyclerView中,维护的是ItemDecoration集合,layout过程中measure ItemView时,会累积计算ItemDecoration集合中offset的值。个人在使用ItemDecoration时,出现过重复对RecyclerView设置同一个ItemDecoration,导致间距表现不符预期的情况,多发生在页面刷新场景。

3.6 ItemAniamtor

最后再简单介绍ItemAniamtor,它用来定义Adapter中维护的数据集发生变化时ItemView需要执行的动画效果,例如删除某个正在展示中的ItemView对应的Item数据时,该ItemView需要执行的消失动画,以及由于它的消失其它ItemView需要执行的位移动画等。

此处介绍它是为了纠正望文生义而对ItemAnimator产生的错误预期,为了完成背景中提到的delay位移动画效果,曾寄希望于ItemAnimator帮助实现,实际上基于4.2节与4.3节中的分析可以知道,RecyclerView在正常的列表滚动时不会触发ItemAnimator中定义的动画,只有RecyclerView布局时才可能触发ItemAnimator的动画。由于ItemAnimator的内容也及其庞大,本文不会对这部分内容继续展开。

4.LayoutManager的工作

上面介绍了这么多RecyclerView的成员,终于轮到了LayoutManager。相信每个人都听说过:LayoutManager负责RecyclerView的布局。但这句是说LayoutManager取代了RecyclerView的onLayout方法吗?并不是,LayoutManager的工作实际上是帮助RecyclerView决定子View的位置,并且这项工作并不一定只在RecyclerView.onLayout方法中完成。这一节的内容是了解LayoutManager在什么时机需要做什么样的工作。

4.1 RecyclerView如何实现布局与绘制

为了了解LayoutManager是在什么时机开始布局ItemView,可以先回到RecyclerView中,RecyclerView作为一个ViewGroup,逃不掉measure、layout、draw三大流程。

4.1.1 measure

RecyclerView为LayoutManager提供了自定义onMeasure方法的机会,如果LayoutManager期望RecyclerView使用自定义的onMeasure方法,可以通过重写isAutoMeasureEnabled方法返回false禁用RecyclerView的autoMeasure策略,实际上,该方法默认返回false,但大多情况下,常用的LayoutManager此处都返回true。需要特别注意的是,当isAutoMeasureEnabled返回true时,不应重写LayoutManager的onMeasure方法。如市面上大多文章一样,本文也省略了非autoMeasure部分的分析,着重讨论autoMeasure的机制。

RecyclerView的onMeasure的autoMeasure处理相对简单,包含以下两个分支:

  1. 如果RecyclerView固定宽高,则调用RecyclerView的defaultOnMeasure方法结束onMeasure。
  2. 如果RecyclerView是自适应的宽高,则需要提前布局ItemView,才能确定RecyclerView的宽高,因此RecyclerView的onMeasure方法中会提前进行layout的部分过程。

4.1.2 layout

RecyclerView的layout过程分为3个步骤,且三个步骤对应的方法命名也非常简单粗暴:

  • dispatchLayoutStep1
  • dispatchLayoutStep2
  • dispatchLayoutStep3

与之相对应的是RecyclerVIew中State类(State类中记录各种可能会使用到的信息)中的mLayoutStep变量可能的三个取值(mLayoutStep变量实际上是int类型,为了便于阅读这里列出其可能取值的常量名):

  • STEP_START
  • STEP_LAYOUT
  • STEP_ANIMATIONS

image.png

RecyclerView中一次完整的layout过程需要至少调用一次dispatchLayoutStep1、dispatchLayoutStep2、dispatchLayoutStep3,其中dispatchLayoutStep2可能被多次调用。在3.1.1中提到,onMeasure中可能会提前进行layout的部分过程,是指dispatchLayoutStep1和dispatchLayoutStep2,如果onMeasure中已经完成layout的前两步工作,大多数情况下onLayout中仅需执行dispatchLayoutStep3即可,如果onMeasure中未提前进行layout的前两步,则需要在onLayout中完整的执行一次layout过程。

虽然经常说RecyclerView将layout的能力外包给LayoutManager处理,但实际上RecyclerView只是将布局子View的能力交由LayoutManager处理,RecyclerView在layout过程中还会进行预布局pre-layout等其它操作。

在layout过程中,第二步即dispatchLayoutStep2中会调用LayoutManager的onLayoutChildren方法,这一步通常也被认为是实际的布局过程post-layout,在这一步将需要在屏幕上展示的ItemView添加到RecyclerView中,并进行ItemView的measure和layout;layout过程中的第一步与第三步则主要是为了服务于RecyclerView的动画(ItemAnimator),在第一步先进行一次pre-layout,再在第三步比较pre-layout和post-layout的区别,进而触发ItemAnimator的动画执行。

4.1.3 draw

RecyclerView的绘制过程中特殊处理相对较少,本文只对ItemDecoration相关的流程简单介绍。在绘制ItemView前,RecyclerView会先遍历其维护的ItemDecoration列表,执行ItemDecoration的onDraw方法,绘制出的内容在ItemView的下层;ItemView完成绘制后,执行ItemDecoration的onDrawOver方法,绘制出的内容在ItemView的上层。

4.2 滚动处理

本文主要目标是为实现自定义LayoutManager做必要准备,因此不展开RecyclerView复杂的嵌套滚动和惯性滚动等逻辑,只考虑LayoutManager在普通的滚动处理中起到的作用。列表的滚动通常是用户的手势操作引起的,先将目光放到RecyclerView.onTouchEvent方法中。

我们知道,用户的手指在列表上移或下移,会导致列表滚动,因此我们到RecyclerView.onTouchEvent的ACTION_MOVE分支中,观察它有没有与scroll相关的处理,发现RecyclerView在接收到ACTION_MOVE的消息后,经过了一系列的计算与判断,可以得到手势滑动导致的列表水平方向和垂直方向的位移dx与dy,然后调用RecyclerView内部的scrollByInternal方法处理滚动的位移值dx与dy,最终进入scrollStep方法,根据dx与dy分别调用LayoutManager的scrollHorizontallyBy/scrollVerticallyBy方法,把滚动导致的子View的移动和布局工作外包给了LayoutManager处理,同时LayoutManager在处理滚动时也需要及时的使用Recycler处理不在屏幕中继续展示的View。

关于RecyclerView滚动需要注意的是,以谷歌提供的LinearLayoutManager为例,它在处理滚动时,是调用View提供的offsetTopAndBottom方法平移已经展示在屏幕中的ItemView,并使用fill方法向滚动产生的空白区域添加View和处理已经不在屏幕展示的View,在这个过程中,与LayoutManager.onLayoutChildren方法并无关联。一次正常的滚动过程不会导致RecyclerView的重复布局,因此一次正常的列表滚动不会触发ItemAnimator的任何动画。

4.3 数据更新处理

RecyclerView在设置Adapter时,会创建RecyclerViewDataObserver对象注册监听Adapter中的Observable。RecyclerViewDataObserver做的事情其实就是在Adapter的数据集发送改变或其中的某个数据发生改变时,在合适的情况下requestLayout,重新完成一次RecyclerView的layout过程,这个时候才是触发ItemAnimator相应动画的时机。

关于观察者模式此处不做赘述,只需明确注册监听后,RecyclerView可以接收到Adapter.notifyXXX的消息即可,然后将注意力放到RecyclerViewDataObserver中,关注其对notify消息的具体处理。

首先明确数据更新的几种类型:

  • 数据集全量更新(DataSetChanged)
  • 数据集局部更新
    • 局部Item改变(ItemChanged/ItemRangeChanged)
    • 新的Item插入(ItemInserted)
    • 已有Item删除(ItemRemoved)
    • 已有Item移动(ItemMoved)

观察RecyclerViewDataObserver中用来处理数据更新的方法,发现这些方法中都使用到了同一个帮助类:AdapterHelper。在AdapterHelper中,将数据更新行为抽象成UpdateOp类,每个UpdateOp对象表示一次数据更新操作,AdapterHelper中维护着一个待处理的更新操作列表mPendingUpdates(ArrayList)。

如果Adapter触发了一次全量更新,那么RecyclerViewDataObserver中的处理方法会在mPendingUpdates列表为空时requestLayout,进而触发RecyclerView的重新布局;如果Adapter触发了局部更新(包括ItemChange/ItemInsert/ItemRemove/ItemMove等),那么RecyclerViewDataObserver中的处理方法会在mPendingUpdates列表的size为1时requestLayout触发RecyclerView的重新布局。

4.小结

至此,已经了解RecyclerView的几个重要成员和它们的基本职责,以及它们与LayoutManager之间的关联:

  1. Adapter根据数据对象的type提供View,并提供View和数据间的绑定关系,LayoutManager不需要与Adapter打交道。
  2. Recyler可以根据position提供一个可以直接用来展示的View,它还负责管理已经不被展示的View。LayoutManager需要直接与Recycler打交道,它在onLayoutChildren时向Recycler索要可以用来展示的View,并在处理滑动时将不再展示的View交由Recycler处理。
  3. ItemDecoration可以处理ItemView的布局偏移,LayoutManager在measure ItemView时会将其计算在内。
  4. ItemAnimator用来定义数据集发生改变时ItemView需要执行的动画,LayoutManager与其并无直接的联系。ItemAnimator定义的动画的执行时机是由RecyclerView的layout过程触发的,正常的列表滑动不会触发RecyclerView的重复布局,因此列表滑动时也不会触发ItemAnimator的执行。

另外,从上述描述中可以知道LayoutManager需要完成的两个重要工作:

  1. 在onLayoutChildren方法中处理ItemView的布局。
  2. 在scrollHorizontallyBy和scrollVerticallyBy方法中处理列表滚动时ItemView的平移以及ItemView的补充和回收。

此时我们了解到,LayoutManager可以处理子View的measure和layout过程,它可以按自己的需要measure child,并把子View放在它期望的位置上(甚至可以把所有子View都叠放在同一个位置);LayoutManager还可以接管处理滚动的过程(如果愿意的话我们甚至可以在scroll方法中重新布局子View而不触发RecyclerView的layout过程)。

再次回看第一节中的demo,需求是在布局或滑动时,保证第一个完全可见的ItemView是大卡片态,其它ItemView是小卡片态,而布局和滑动恰好可以在LayoutManager进行处理,于是需求变成了让LayoutManager measure第一个完全可见的ItemView时处理为大卡片态,其它ItemView measure为小卡片态,并让LayoutManager将这些ItemView布局到正确的位置上。在后续文章中,我们将带领大家从0到1分析如何实现一个满足我们滚动动画需求的LayoutManager。

hi, 我是快手电商的鱼塘

快手电商无线技术团队正在招贤纳士🎉🎉🎉! 我们是公司的核心业务线, 这里云集了各路高手, 也充满了机会与挑战. 伴随着业务的高速发展, 团队也在快速扩张. 欢迎各位高手加入我们, 一起创造世界级的电商产品~

热招岗位: Android/iOS 高级开发, Android/iOS 专家, Java 架构师, 产品经理(电商背景), 测试开发... 大量 HC 等你来呦~

内部推荐请发简历至 >>>我们的邮箱: hr.ec@kuaishou.com <<<, 备注我的花名成功率更高哦~ 😘

分类:
Android
标签:
收藏成功!
已添加到「」, 点击更改