λ:
# 倉庫地址: https://github.com/lzyprime/android_demos/tree/recyclerview
git clone -b recyclerview https://github.com/lzyprime/android_demos
RecyclerView
作 Android 列表項的展示組件鸟辅。相比ListView
椿每,緩存機制做的更細致萧朝,提升流暢度略荡。以空間換時間
兩個重要參數(shù):
-
LayoutManager
: 排版 -
RecyclerView.Adapter
: 列表項獲取方式
LayoutManager
LayoutManager
可以在xml
中直接配置. 也可在邏輯代碼中設(shè)置。
// xml
<androidx.recyclerview.widget.RecyclerView
...
// LayoutManager類型
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
// 幾欄
app:spanCount="1"
/>
全部可配參數(shù):
1. LinearLayoutManager
public class LinearLayoutManager extends RecyclerView.LayoutManager implements
ItemTouchHelper.ViewDropHandler, RecyclerView.SmoothScroller.ScrollVectorProvider
單欄線性布局满哪。無法多欄展示婿斥。構(gòu)造函數(shù)參數(shù):
- orientation: 方向
- reverseLayout: 反轉(zhuǎn)劝篷,倒序列表項
stackFromEnd 用來兼容 android.widget.AbsListView.setStackFromBottom(boolean)。相當(dāng)于reverseLayout 的效果民宿。
同時實現(xiàn)了ItemTouchHelper.ViewDropHandler
, RecyclerView.SmoothScroller.ScrollVectorProvider
2. GridLayoutManager
public class GridLayoutManager extends LinearLayoutManager
網(wǎng)格布局娇妓。LinearLayoutManager
升級版,可以通過spanCount
設(shè)置分幾欄
3. StaggeredGridLayoutManager
public class StaggeredGridLayoutManager extends RecyclerView.LayoutManager implements
RecyclerView.SmoothScroller.ScrollVectorProvider
流布局活鹰。 當(dāng)列表項尺寸不一致時, GridLayoutManager
根據(jù)尺寸較大項確定網(wǎng)格尺寸哈恰。導(dǎo)致較小項會有空白部分。StaggeredGridLayoutManager
則緊湊拼接每一項华望。 通過 setGapStrategy(int)
設(shè)置間隙處理策略蕊蝗。
Adapter
RecyclerView.Adapter<VH : RecyclerView.ViewHolder>
public abstract static class Adapter<VH extends ViewHolder> {
...
@NonNull
public abstract VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType);
public abstract void onBindViewHolder(@NonNull VH holder, int position);
public abstract int getItemCount();
}
public abstract static class ViewHolder {
public ViewHolder(@NonNull View itemView) { ... }
}
一個Adapter
至少需要override
這三個函數(shù)。
getItemCount
返回列表項的個數(shù)赖舟。
onCreateViewHolder
, getItemViewType
創(chuàng)建一個ViewHolder
, 如果 ViewHolder
有多種類型蓬戚,可以通過viewType
參數(shù)判斷。 viewType
的值來自 getItemViewType(position: Int)
函數(shù)宾抓。默認返回0子漩。 0 <= position < getItemCount()
以聊天消息為例:
sealed class Msg {
data class Text(val content: String) : Msg()
data class Image(val url: String) : Msg()
data class Video(...) : Msg()
...
}
class MsgListAdapter : RecyclerView.Adapter<MsgListAdapter.MsgViewHolder>() {
sealed class MsgViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
class Text(...) : MsgViewHolder(...)
class Image(...) : MsgViewHolder(...)
...
}
private var dataList: List<Msg> = listOf()
override fun getItemViewType(position: Int): Int =
when (dataList[position]) {
is Msg.Text -> 1
is Msg.Image -> 2
...
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MsgViewHolder =
when (viewType) {
1 -> MsgViewHolder.Text(...)
2 -> MsgViewHolder.Image(...)
...
}
}
onBindViewHolder
View
創(chuàng)建完成,開始綁定數(shù)據(jù)石洗。包括事件監(jiān)聽注冊幢泼。
class VBViewHolder<VB : ViewBinding>(private val binding : VB) : ViewHolder(binding.root) {
fun bind(data: T, onClick:() -> Unit) {
binding.data = data
...
binding.anyView.setOnClickListener { onClick() }
...
}
}
class Adapter(private val onItemClick: () -> Unit) : RecyclerView.Adapter<VBViewHolder<XXX>>() {
override fun onBindViewHolder(holder: VBViewHolder<XXX>, position: Int) =
holder.bindHolder(dataList[position], onItemClick)
}
更新
由于緩存機制,更新完數(shù)據(jù)源, ViewHolder
也并不會立刻刷新讲衫。需要通過Adapter
的一系列方法缕棵,顯式通知發(fā)生變化的列表項。
notifyDataSetChanged()
notifyItemChanged(position: Int), notifyItemChanged(position: Int, payload: Any?)
notifyItemRangeChanged(positionStart: Int, itemCount: Int), notifyItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?)
notifyItemMoved(fromPosition: Int, toPosition: Int)
notifyItemInserted(position: Int)
notifyItemRangeInserted(positionStart: Int, itemCount: Int)
notifyItemRemoved(position: Int)
notifyItemRangeRemoved(positionStart: Int, itemCount: Int)
payload: Any?
要配合 Adapter
的 onBindViewHolder(holder: VH, position: Int, payloads: MutableList<Any>)
實現(xiàn) View
的局部刷新涉兽。否則招驴,執(zhí)行 onBindViewHolder(holder: VBViewHolder<VH>, position: Int)
緩存機制
主要邏輯在 RecyclerView.Recycler
。 緩存主要有 Scrap
, CachedView
, RecycledViewPool
枷畏。 ViewCacheExtension
用于額外自定義緩存别厘。
-
Scrap
: 當(dāng)前正在展示的部分。 -
CachedView
: 剛劃出展示區(qū)域的部分拥诡,默認最大存儲DEFAULT_CACHE_SIZE = 2
触趴。FIFO
更新 -
RecycledViewPool
:CachedView
淘汰后,只保留ViewHolder
, 清空數(shù)據(jù)綁定渴肉。 復(fù)用時需要重新執(zhí)行onBindViewHolder
冗懦。
RecycledViewPool
內(nèi)部是一個SparseArray<ScrapData>
下標為 holder.viewType
。ScrapData
內(nèi)嵌ArrayList<ViewHolder>
, 默認最大存儲 DEFAULT_MAX_SCRAP = 5
個 ViewHolder
宾娜。 所以簡化一下RecycledViewPool ~= SparseArray<ArrayList<ViewHolder>>
批狐。
public final class Recycler {
final ArrayList<ViewHolder> mAttachedScrap = new ArrayList<>();
ArrayList<ViewHolder> mChangedScrap = null;
final ArrayList<ViewHolder> mCachedViews = new ArrayList<ViewHolder>();
private final List<ViewHolder>
mUnmodifiableAttachedScrap = Collections.unmodifiableList(mAttachedScrap);
private int mRequestedCacheMax = DEFAULT_CACHE_SIZE;
int mViewCacheMax = DEFAULT_CACHE_SIZE;
RecycledViewPool mRecyclerPool;
private ViewCacheExtension mViewCacheExtension;
static final int DEFAULT_CACHE_SIZE = 2;
...
}
public static class RecycledViewPool {
private static final int DEFAULT_MAX_SCRAP = 5;
static class ScrapData {
final ArrayList<ViewHolder> mScrapHeap = new ArrayList<>();
int mMaxScrap = DEFAULT_MAX_SCRAP;
long mCreateRunningAverageNs = 0;
long mBindRunningAverageNs = 0;
}
SparseArray<ScrapData> mScrap = new SparseArray<>();
private int mAttachCount = 0;
...
}
取, getViewForPosition
跟一下該函數(shù)就大概知道各級緩存如何配合。
@NonNull
public View getViewForPosition(int position) {
return getViewForPosition(position, false);
}
View getViewForPosition(int position, boolean dryRun) {
return tryGetViewHolderForPositionByDeadline(position, dryRun, FOREVER_NS).itemView;
}
@Nullable
RecyclerView.ViewHolder tryGetViewHolderForPositionByDeadline(int position, boolean dryRun, long deadlineNs) {
...
boolean fromScrapOrHiddenOrCache = false;
RecyclerView.ViewHolder holder = null;
// 0) If there is a changed scrap, try to find from there
if (mState.isPreLayout()) {
holder = getChangedScrapViewForPosition(position);
fromScrapOrHiddenOrCache = holder != null;
}
// 1) Find by position from scrap/hidden list/cache
if (holder == null) {
holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
...
}
if (holder == null) {
final int offsetPosition = mAdapterHelper.findPositionOffset(position);
...
final int type = mAdapter.getItemViewType(offsetPosition);
// 2) Find from scrap/cache via stable ids, if exists
if (mAdapter.hasStableIds()) {
holder = getScrapOrCachedViewForId(mAdapter.getItemId(offsetPosition), type, dryRun);
...
}
if (holder == null && mViewCacheExtension != null) {
// We are NOT sending the offsetPosition because LayoutManager does not
// know it.
final View view = mViewCacheExtension.getViewForPositionAndType(this, position, type);
...
}
if (holder == null) { // fallback to pool
...
holder = getRecycledViewPool().getRecycledView(type);
...
}
if (holder == null) {
...
holder = mAdapter.createViewHolder(RecyclerView.this, type);
...
}
}
...
return holder;
}
getChangedScrapViewForPosition
getScrapOrHiddenOrCachedHolderForPosition
getScrapOrCachedViewForId
mViewCacheExtension.getViewForPositionAndType
getRecycledViewPool().getRecycledView(type)
mAdapter.createViewHolder(RecyclerView.this, type)
放前塔,recycleView
跟一下該函數(shù)嚣艇,了解放入緩存過程和策略
public void recycleView(@NonNull View view) {
ViewHolder holder = getChildViewHolderInt(view);
... // 清空flag
recycleViewHolderInternal(holder);
...
}
void recycleViewHolderInternal(ViewHolder holder) {
...
final boolean transientStatePreventsRecycling = holder.doesTransientStatePreventRecycling();
@SuppressWarnings("unchecked") final boolean forceRecycle = mAdapter != null && transientStatePreventsRecycling && mAdapter.onFailedToRecycleView(holder);
boolean cached = false;
boolean recycled = false;
if (forceRecycle || holder.isRecyclable()) {
if (mViewCacheMax > 0 && !holder.hasAnyOfTheFlags(...)) {
// Retire oldest cached view
int cachedViewSize = mCachedViews.size();
if (cachedViewSize >= mViewCacheMax && cachedViewSize > 0) {
recycleCachedViewAt(0);
cachedViewSize--;
}
int targetCacheIndex = cachedViewSize;
if (ALLOW_THREAD_GAP_WORK && cachedViewSize > 0 && !mPrefetchRegistry.lastPrefetchIncludedPosition(holder.mPosition)) {
// when adding the view, skip past most recently prefetched views
int cacheIndex = cachedViewSize - 1;
while (cacheIndex >= 0) {
int cachedPos = mCachedViews.get(cacheIndex).mPosition;
if (!mPrefetchRegistry.lastPrefetchIncludedPosition(cachedPos)) {
break;
}
cacheIndex--;
}
targetCacheIndex = cacheIndex + 1;
}
mCachedViews.add(targetCacheIndex, holder);
cached = true;
}
if (!cached) {
addViewHolderToRecycledViewPool(holder, true);
recycled = true;
}
} else {
... // Log
}
// even if the holder is not removed, we still call this method so that it is removed
// from view holder lists.
mViewInfoStore.removeViewHolder(holder);
if (!cached && !recycled && transientStatePreventsRecycling) {
holder.mBindingAdapter = null;
holder.mOwnerRecyclerView = null;
}
}
mCachedViews.add(targetCacheIndex, holder)
addViewHolderToRecycledViewPool
簡化 & 封裝 & 工具
一個 Adapter
的實現(xiàn)皆撩,大多數(shù)時候只關(guān)注 onBindViewHolder
的過程溃列,以及數(shù)據(jù)更新時 notify
更新邏輯。剩下的操作蝗肪,基本是重復(fù)的寂屏。
ListAdapter
默認實現(xiàn)了fun getItemCount() = dataList.size()
贰谣。
需要一個 DiffUtil.ItemCallback<T>
,內(nèi)部構(gòu)造mDiffer: AsyncListDiffer<T>
, 用于比較列表項的變化迁霎,然后自動刷新吱抚。
通過 submitList(List<T>?)
提交數(shù)據(jù)。
通過 getItem(position: Int): T = dataList[position]
獲取當(dāng)前位置對應(yīng)數(shù)據(jù)考廉。
省去了數(shù)據(jù)更新和notify
的過程秘豹, 只需要關(guān)注onCreateViewHolder
, onBindViewHolder
。
PS: 注意 submitList()
和傳引用問題昌粤。 做數(shù)據(jù)比較時 previousList, currentList
以及 Item
的比較既绕,全是靠引用拿到,diff(previousList[index], currentList[index])
涮坐。所以如果 submitList()
如果提交的同一份List
凄贩, diff比較就會失效。
如果使用 Paging3
分頁庫, 在View層會有 PagingDataAdapter
, 與 ListAdapter
類似袱讹。 將數(shù)據(jù)源 PagingData
等設(shè)置好后疲扎,列表便可以自動刷新,加載更多等捷雕。
public abstract class ListAdapter<T, VH extends RecyclerView.ViewHolder>
extends RecyclerView.Adapter<VH> {
final AsyncListDiffer<T> mDiffer;
private final AsyncListDiffer.ListListener<T> mListener = ...;
protected ListAdapter(@NonNull DiffUtil.ItemCallback<T> diffCallback) { ... }
protected ListAdapter(@NonNull AsyncDifferConfig<T> config) { ... }
public void submitList(@Nullable List<T> list) { mDiffer.submitList(list); }
public void submitList(@Nullable List<T> list, @Nullable final Runnable commitCallback) { mDiffer.submitList(list, commitCallback); }
protected T getItem(int position) { return mDiffer.getCurrentList().get(position); }
@Override public int getItemCount() { return mDiffer.getCurrentList().size(); }
@NonNull public List<T> getCurrentList() { return mDiffer.getCurrentList(); }
public void onCurrentListChanged(@NonNull List<T> previousList, @NonNull List<T> currentList) {}
}
DSL + ViewBinding
繼續(xù)簡化椒丧。
大部分
ViewHolder
靠ViewBinding
實現(xiàn)。 那onCreateViewHolder()
也基本是重復(fù)的操作非区。ViewBinding
的創(chuàng)建過程也基本一致:ViewBinding.inflate(...)
瓜挽。 可以用 《android ViewBinding, DataBinding》 中的老方法,靠反射拿到征绸。所以只需要Adapter<VB : ViewBinding>
久橙,onCreateViewHolder()
也可以省了。DiffUtil.ItemCallback<T>
的實現(xiàn)也基本重復(fù)管怠。 通常只需要兩個lambda表達式說明情況淆衷。
// ViewHolder
data class BindingViewHolder<VB : ViewBinding>(val binding: VB) : RecyclerView.ViewHolder(binding.root)
// DiffUtil.ItemCallback<T>
inline fun <reified T> diffItemCallback(
crossinline areItemsTheSame: (oldItem: T, newItem: T) -> Boolean,
crossinline areContentsTheSame: (oldItem: T, newItem: T) -> Boolean = { o, n -> o == n },
) = object : DiffUtil.ItemCallback<T>() {
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean =
areItemsTheSame(oldItem, newItem)
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean =
areContentsTheSame(oldItem, newItem)
}
// ListAdapter<T, VH : ViewHolder>
fun <T, VH : RecyclerView.ViewHolder> dslListAdapter(
diffItemCallback: DiffUtil.ItemCallback<T>,
createHolder: (parent: ViewGroup, viewType: Int) -> VH,
bindHolder: VH.(position: Int, data: T) -> Unit,
) = object : ListAdapter<T, VH>(diffItemCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
createHolder(parent, viewType)
override fun onBindViewHolder(holder: VH, position: Int) =
holder.bindHolder(position, getItem(position))
}
/**
* ListAdapter<T, BindingViewHolder<VB : ViewBinding>>
*
* inflate 不傳時,通過反射拿到VB的inflate
* */
inline fun <T, reified VB : ViewBinding> dslBindingListAdapter(
diffItemCallback: DiffUtil.ItemCallback<T>,
noinline inflate: ((parent: ViewGroup, viewType: Int) -> VB)? = null,
crossinline bindHolder: VB.(position: Int, data: T) -> Unit,
) = dslListAdapter(
diffItemCallback,
{ p, v ->
BindingViewHolder(
inflate?.invoke(p, v) ?: VB::class.java.getMethod(
"inflate",
LayoutInflater::class.java,
ViewGroup::class.java,
Boolean::class.java
).invoke(null, LayoutInflater.from(p.context), p, false) as VB
)
},
{ p, d -> binding.bindHolder(p, d) },
)
使用:
val adapter = dslBindingListAdapter<Comment, ListItemSingleLineTextBinding>(
diffItemCallback({ o, n -> o.id == n.id }, { o, n -> o == n }),
) { _, data ->
// this is ListItemSingleLineTextBinding,
// data: Comment(id: Int, content: String)
titleText.text = data
}
此外還有各種庫也做了封裝渤弛。最好靠(ksp祝拯,kapt)注解和編譯器插件在編譯期做代碼生成,靠反射不保險還額外費資源
ItemTouchHelper
列表項滑動和拖拽。
public class ItemTouchHelper extends RecyclerView.ItemDecoration implements RecyclerView.OnChildAttachStateChangeListener
// use:
ItemTouchHelper(callback: ItemTouchHelper.Callback).attachToRecyclerView(recyclerView: RecyclerView?)
ItemTouchHelper.Callback
需要設(shè)定滑動和拖拽的方向START(LEFT), END(RIGHT), UP, DOWN
佳头。
可通過onChildDraw(), onChildDrawOver()
等自定義滑動和拖拽過程中的行為鹰贵。
object: ItemTouchHelper.Callback() {
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
// 返回滑動和拖拽的方向
}
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
viewHolder // 被拖拽holder
target // 正在經(jīng)過holder
// 返回是否允許滑動
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
direction // 滑動方向
}
}
ItemTouchHelper.SimpleCallback
ItemTouchHelper.Callback
簡版實現(xiàn)。構(gòu)造函數(shù)傳入滑動和拖拽方向康嘉。只需要關(guān)注onMove()
和 onSwiped()
過程碉输。
public abstract static class SimpleCallback extends Callback {
public SimpleCallback(int dragDirs, int swipeDirs)
...
}
自定義行為
override fun onChildDraw(
c: Canvas, // holder所占區(qū)域的Canvas
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
dX: Float, // 用戶動作引起的x移量
dY: Float, // 用戶動作引起的y移量
actionState: Int, // 交互類型,swipe | drag
isCurrentlyActive: Boolean, // 用戶是否正在控制
) { ... }
onChildDraw
的默認實現(xiàn):translationX = dX, translationY = dY
public void onDraw(Canvas c, RecyclerView recyclerView, View view, float dX, float dY,
int actionState, boolean isCurrentlyActive) {
...
view.setTranslationX(dX);
view.setTranslationY(dY);
}
dX, dY:
關(guān)于dX, dY
的計算規(guī)則亭珍,要從頭一點點看敷钾,attachToRecyclerView()
之后。
public void attachToRecyclerView(@Nullable RecyclerView recyclerView) { ...
setupCallbacks();
... }
private void setupCallbacks() { ...
mRecyclerView.addOnItemTouchListener(mOnItemTouchListener);
... }
private final OnItemTouchListener mOnItemTouchListener = new OnItemTouchListener() { ...
select(...)
... };
void select(@Nullable ViewHolder selected, int actionState) { ...
swipeIfNecessary(...)
... }
private int swipeIfNecessary(ViewHolder viewHolder) {
checkHorizontalSwipe(...)
checkVerticalSwipe(...)
}
// flags: 方向
private int checkHorizontalSwipe(ViewHolder viewHolder, int flags) { ...
mCallback.getSwipeVelocityThreshold(...) // 速度臨界點
mCallback.getSwipeEscapeVelocity(...) // 最小速度
final float threshold = mRecyclerView.getWidth() *
mCallback.getSwipeThreshold(viewHolder); // 位置臨界點肄梨,默認0.5
... }
主要關(guān)注swipe
過程阻荒,以及松手之后。
// 以水平滑動為例:
// 如果是默認行為: dx == holder.translationX
val oldDX // 開始滑動時的位置众羡,也就是上次停止的位置侨赡。 abs(oldDX) == 0 || abs(oldDX) == holder.width
// 正在滑動時
val diffX: Int // 手指滑動偏移量
dX = oldDX + diffX
// 松開時:
val isSwiped = 是否超過了速度臨界點或者位置臨界點
if(true) {
// 如果超過,dx 最終值根據(jù)oldDX和滑動方向確定纱控。
// 最終值 = 如果之前為未滑動狀態(tài)辆毡,則劃出屏幕。如果之前未劃出屏幕甜害,則置為未滑動
// 值變化靠動畫補全
dx = anim(curDX -> (abs(oldDX) == 0 ? holder.width : 0) * (direction == LEFT ? -1 : 1))
} else {
// 如果未超過舶掖,dx開始還原回初始值
dx = anim(curDX -> oldDX)
}
松手后會根據(jù)是否超過臨界值,而選擇最終位置尔店。
demo: 添加震動, 半透明效果, 自定義繪制等
override fun onChildDraw(
c: Canvas,
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
dX: Float,
dY: Float,
actionState: Int,
isCurrentlyActive: Boolean
) {
val midWidth = c.width / 2
val absCurrentX = abs(viewHolder.itemView.translationX)
// 震動
if (absCurrentX < midWidth && abs(dX) >= midWidth) {
val vibrator = requireContext().getSystemService(Vibrator::class.java) as Vibrator
if (vibrator.hasVibrator()) {
vibrator.vibrate(VibrationEffect.createOneShot(50, 255))
}
}
// 半透明
viewHolder.itemView.alpha = if (absCurrentX >= midWidth) 0.5f else 1f
// 背景
if (dX != 0f) {
c.drawRect(
0f,
viewHolder.itemView.top.toFloat(),
c.width.toFloat(),
viewHolder.itemView.bottom.toFloat(),
Paint().apply { color = Color.RED },
)
}
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
}
二次滑動眨攘,展示側(cè)滑菜單
最難控的就是dX, dY
的變化∠荩可以把getSwipeVelocityThreshold
速度臨界點禁掉鲫售,只靠位置推算是否滑動成功。同時還要判斷失去焦點時還原该肴。
雖然能寫出來情竹,但是并不穩(wěn)定。如果真有需求匀哄,不如自己實現(xiàn)ItemTouchHelper
秦效,大部分代碼不用動,修改滑動判定涎嚼,和松手后anim動畫設(shè)置即可阱州。
ConcatAdapter
Adapter
拼接。
需要引入recyclerview
庫
implementation("androidx.recyclerview:recyclerview:latest")
public ConcatAdapter(@NonNull Adapter<? extends ViewHolder>... adapters)
public ConcatAdapter(@NonNull List<? extends Adapter<? extends ViewHolder>> adapters)
@SafeVarargs
public ConcatAdapter(
@NonNull Config config,
@NonNull Adapter<? extends ViewHolder>... adapters)
public ConcatAdapter(
@NonNull Config config,
@NonNull List<? extends Adapter<? extends ViewHolder>> adapters)
~λ:
2.25 開始寫法梯,現(xiàn)在 3.4 了苔货。雖然內(nèi)容多,中間也斷斷續(xù)續(xù),但寫總結(jié)仍然很耗時夜惭。 單純看這些源碼姻灶,寫demo,也就花一下午滥嘴,但整理要花這么久木蹬。
沒有需求一陣子了至耻。公司客戶端開發(fā)需求并不多若皱。而且本來也是我的個人愛好,當(dāng)初只是人手不夠暫時支援尘颓,結(jié)果越走越遠走触,快要回不去后端了。
現(xiàn)在沒有需求疤苹,打算回后端互广,從工作以來,寫客戶端(kotlin, flutter)的時間比后端還多卧土。頂多LeetCode刷題用一下語言(kotlin, scala, c++, rust
)惫皱,平時自己玩一下Linux
,但真正的開發(fā)尤莺,沒怎么正經(jīng)寫過旅敷。
所以,就算換工作颤霎,也只能投遞客戶端媳谁,投后端基本沒戲。