一起聯(lián)動(dòng)吧!Android CoordinatorLayout 自定義 Behavior

聯(lián)動(dòng)效果

現(xiàn)代化的 Android 開發(fā)一定對(duì) CoordinatorLayout 不陌生,CoordinatorLayout + AppBarLayout + CollapsingToolbarLayout + Toolbar 的全家桶更是信手拈來,無需一行代碼光靠 xml 就能實(shí)現(xiàn)下面這種折疊導(dǎo)航欄的炫酷效果:

著作權(quán)歸作者所有。商業(yè)轉(zhuǎn)載請(qǐng)聯(lián)系作者獲得授權(quán),非商業(yè)轉(zhuǎn)載請(qǐng)注明出處。
這種搭配的教程已經(jīng)非常多了,不是本文的重點(diǎn)。在使用 xml 時(shí)候肯定不少同學(xué)掉過一個(gè)坑:界面主要內(nèi)容與頭部元素重疊了!粗略了解一下因?yàn)?CoordinatorLayout 的布局方式類似 FrameLayout 默認(rèn)情況下所有元素都會(huì)疊加在一起,解決方案也非常玄學(xué),就是給內(nèi)容元素添加一個(gè) app:layout_behavior="@string/appbar_scrolling_view_behavior" 屬性就好了,簡(jiǎn)直像黑魔法!

Unfortunately,代碼并沒有魔法,我們能偷懶是因?yàn)橛腥朔庋b好了。跟蹤進(jìn)這個(gè)字符串是 com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior 顯然這是個(gè)類!事實(shí)上這就是今天的重頭戲 —— Behavior.

這個(gè)效果太復(fù)雜了,所以 Google 才會(huì)幫我們包裝好,下面換一個(gè)簡(jiǎn)單的例子便于學(xué)習(xí):


這是仿三星 One UI 的界面。上面是一個(gè)頭布局,下面是一個(gè) RecyclerView,向上滑動(dòng)時(shí)首先頭布局收縮漸隱并有個(gè)視差效果,頭部徹底隱藏后 RecyclerView 無縫銜接。向下滑動(dòng)時(shí)同理。

事件攔截實(shí)現(xiàn)

在繼續(xù)探索之前,先思考一下如果沒有 CoordinatorLayout 這種現(xiàn)代化東西怎么辦?因?yàn)檫@牽扯到滑動(dòng)手勢(shì)與 View 效果的糅合,毫無疑問應(yīng)該從觸摸事件上入手。簡(jiǎn)單起見暫時(shí)只考慮手指向上滑動(dòng)(列表向下展示更多內(nèi)容),大概需要進(jìn)行以下操作:

  1. 在父布局 onInterceptTouchEvent 中攔截事件。
  2. 父布局 onTouchEvent 處理事件,對(duì) HeaderView 進(jìn)行操作(移動(dòng)、改變透明度等)。
  3. HeaderView 完全折疊后父布局不再攔截事件,RecyclerView 正常處理滑動(dòng)。

現(xiàn)在已經(jīng)遇到問題了。因?yàn)橐婚_始父布局?jǐn)r截了事件,因此根據(jù) Android 事件分發(fā)機(jī)制,哪怕后續(xù)不再攔截其子控件也無法收到事件,除非重新觸摸,這就造成了兩者的滑動(dòng)不能無縫銜接。

接著還有一個(gè)問題,反過來當(dāng) RecyclerView 向下滑動(dòng)至頂部時(shí),如何通知 HeaderView 展開?

哪怕解決了上述主要問題,肯定還有其他小毛病,例如子控件無法觸發(fā)點(diǎn)擊事件等等等非常惱人??。假設(shè)你是大佬完美解決了所有問題,肯定耦合特別嚴(yán)重,又是自定義 View 又是互相引用的亂七八糟?? 所以現(xiàn)在就不往下深究了,有閑情雅致有能力的同學(xué)可以嘗試實(shí)現(xiàn)。

NestingScroll

從 Android 5.0 (API21) 開始 Google 給出了官方解決方案 - NestingScroll,這是一個(gè)嵌套滑動(dòng)機(jī)制,用于協(xié)調(diào)父/子控件對(duì)滑動(dòng)事件的處理。他的基本思想就是,事件直接傳到子控件,由子控件詢問父控件是否需要滑動(dòng),父控件處理后給出已消耗的距離,子控件繼續(xù)處理未消耗的距離。當(dāng)子控件也滑到頂(底)時(shí)將剩余距離交給父控件處理。讓我來生動(dòng)地解釋一下:

子:開始滑動(dòng)嘍,準(zhǔn)備滑300px,爸爸你要不要先滑?
父:好嘞,我先滑100px到頂了,你繼續(xù)。
子:收到,我接著滑160px到底了,爸爸剩下的交給你了。
父:好的還有40px,我繼續(xù)滑(也可以不滑忽略此回調(diào))

就這樣,父控件沒有攔截事件,而是子控件收到事件后主動(dòng)詢問,在他們的協(xié)調(diào)配合之下完成了無縫滑動(dòng)銜接。為了實(shí)現(xiàn)這點(diǎn),Google 準(zhǔn)備了兩個(gè)接口:NestedScrollingParent, NestedScrollingChild.

NestedScrollingParent 主要方法如下:

  • onStartNestedScroll : Boolean - 是否需要消費(fèi)這次滑動(dòng)事件。(爸爸你要不要先滑?)
  • onNestedScrollAccepted - 確認(rèn)消費(fèi)滑動(dòng)回調(diào),可以執(zhí)行初始化工作。(好嘞我先滑)
  • onNestedPreScroll - 在子控件處理滑動(dòng)事件之前回調(diào)。(我先滑了100px)
  • onNestedScroll - 子控件滑動(dòng)之后的回調(diào),可以繼續(xù)執(zhí)行剩余距離。(還有40px我繼續(xù)滑)
  • onStopNestedScroll - 事件結(jié)束,可以做一些收尾工作。

類似的還有 Fling 相關(guān)接口。

NestedScrollingChild 主要方法如下:

  • startNestedScroll - 開始滑動(dòng)。
  • dispatchNestedPreScroll - 在自己滑動(dòng)之前詢問父組件。
  • dispatchNestedScroll - 在自己滑動(dòng)之后把剩余距離通知父組件。
  • stopNestedScroll - 結(jié)束滑動(dòng)。

以及 Fling 相關(guān)接口和其他一些東西。

最終執(zhí)行順序如下(父控件接受事件、用戶觸發(fā)了拋擲):子startNestedScroll → 父onStartNestedScroll → 父onNestedScrollAccepted ||→ 子dispatchNestedPreScroll → 父onNestedPreScroll ||→ 子dispatchNestedScroll → 父onNestedScroll ||→ 子dispatchNestedPreFling → 父onNestedPreFling ||→ 子dispatchNestedFling → 父onNestedFling ||→ 子stopNestedScroll → 父onStopNestedScroll

RecyclerView 已經(jīng)默認(rèn)實(shí)現(xiàn)了 Child 接口,現(xiàn)在只要給外層布局實(shí)現(xiàn) Parent 接口并作出正確反應(yīng),應(yīng)該就可以達(dá)到目的了,最麻煩的事件轉(zhuǎn)發(fā)已經(jīng)在 RecyclerView 內(nèi)部實(shí)現(xiàn)。但是... 還是需要自己定義個(gè)外部 Layout?似乎依然有點(diǎn)麻煩并且解耦不徹底。

當(dāng)當(dāng)當(dāng)!Behavior 登場(chǎng)!

CoordinatorLayout 名副其實(shí),它是一個(gè)可以協(xié)調(diào)各個(gè)子 View 的布局。注意區(qū)別 NestedScrolling 機(jī)制,后者只能調(diào)度父子兩者的滑動(dòng),而前者可以協(xié)調(diào)所有子 View 的所有動(dòng)作。有了這個(gè)神器后我們不再需要自定義 Layout 來實(shí)現(xiàn)嵌套滑動(dòng)接口了,并且可以實(shí)現(xiàn)更復(fù)雜的效果。CoordinatorLayout 只能提供一個(gè)平臺(tái),具體效果的實(shí)現(xiàn)需要依賴 Behavior. CoordinatorLayout 的所有直接子控件都可以設(shè)置 Behavior,其定義了這個(gè) View 應(yīng)當(dāng)對(duì)觸摸事件做何反應(yīng),或者對(duì)其他 View 的變化做何反應(yīng),成功地將具體實(shí)現(xiàn)從 View 中抽離出來。

CoordinatorLayout 類似于網(wǎng)游的中央服務(wù)器。對(duì)于嵌套滑動(dòng)來說,它實(shí)現(xiàn)了 NestedScrollingParent 接口因此可以接受到子 View 的滑動(dòng)信息,并且分發(fā)給所有子 View 的 Behavior 并將它們的響應(yīng)匯總起來返回給滑動(dòng) View。對(duì)于依賴其他 View 的功能,當(dāng)有 View 屬性發(fā)生改變時(shí)它會(huì)通知所有聲明了監(jiān)聽的子 View 的 Behavior.

注意:無論嵌套多少級(jí)的滑動(dòng)事件都可以被轉(zhuǎn)發(fā)。但是只有直接子 View 可以設(shè)置 Behavior (響應(yīng)事件)或作為被監(jiān)聽的對(duì)象。

除此之外,Behavior 還有 onInterceptTouchEvent, onTouchEvent 方法,重點(diǎn)是它接收到的不僅僅是自己范圍內(nèi)的事件。也就是說現(xiàn)在子 View 可以直接攔截父布局的事件了。利用這一點(diǎn)我們可以輕松做出拖拽移動(dòng),其他 View 跟隨的效果,比如這樣


?
Behavior 像是一個(gè)集大成者,它能夠進(jìn)行事件處理、嵌套滑動(dòng)協(xié)調(diào)、子控件變化監(jiān)聽,甚至還能直接修改布局(onMeasureChild, onLayoutChild 這里面的 Child 指的就是 Behavior 所對(duì)應(yīng)的子控件)這有什么用呢?通過一開始的例子來看看吧。

實(shí)戰(zhàn):仿三星 One UI

再貼一遍效果圖:

先看看布局:

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <LinearLayout
        android:id="@+id/imagesTitleBlockLayout"
        android:layout_width="match_parent"
        android:layout_height="@dimen/title_block_height"
        android:gravity="center"
        android:orientation="vertical"
        app:layout_behavior=".ui.images.NestedHeaderScrollBehavior">

        <TextView
            style="@style/text_view_primary"
            android:text="@string/nav_menu_images"
            android:textSize="40sp" />

        <TextView
            android:id="@+id/imagesSubtitleTextView"
            style="@style/text_view_secondary"
            android:textSize="18sp"
            tools:text="183 images" />
    </LinearLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/imagesRecyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior=".ui.images.NestedContentScrollBehavior"
        tools:listitem="@layout/rv_item_images_img" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

一般來說為了簡(jiǎn)單,我們會(huì)選定1個(gè) View 用于響應(yīng)嵌套滑動(dòng),其他 View 監(jiān)聽此 View來同步改變。HeaderView 的效果比較復(fù)雜我不希望它承擔(dān)太多工作,因此這里讓 RecyclerView 自己處理嵌套滑動(dòng)問題。

這里一個(gè)重要原因是 HeaderView 有了視差效果。否則的話讓 HeaderView 響應(yīng)滑動(dòng),RecyclerView 只需要緊貼著 HeaderView 移動(dòng)就行了,更簡(jiǎn)單。

處理嵌套滑動(dòng)

現(xiàn)在開始編寫 RecyclerView 所需的 Behavior. 第一個(gè)要解決的問題就是重疊,這就需要?jiǎng)倓偺岬降母深A(yù)布局。核心思想是一開始獲取 HeaderView 的高度,作為 RecyclerView 的 Top 屬性,就可以實(shí)現(xiàn)類似 LinearLayout 的布局了。

注意:①為了能夠在 xml 中直接設(shè)置 Behavior 我們得寫一個(gè)帶有 attrs 參數(shù)的構(gòu)造函數(shù)。② <View> 表示 Behavior 所設(shè)置到的 View 類型,因?yàn)檫@里不需要用到 RecyclerView 的特有 API 所以直接寫 View 了。

class NestedContentScrollBehavior(context: Context?, attrs: AttributeSet?) :
        CoordinatorLayout.Behavior<View>(context, attrs) {
    private var headerHeight = 0

    override fun onLayoutChild(parent: CoordinatorLayout, child: View, layoutDirection: Int): Boolean {
        // 首先讓父布局按照標(biāo)準(zhǔn)方式解析
        parent.onLayoutChild(child, layoutDirection)
        // 獲取到 HeaderView 的高度
        headerHeight = parent.findViewById<View>(R.id.imagesTitleBlockLayout).height
        // 設(shè)置 top 從而排在 HeaderView的下面
        ViewCompat.offsetTopAndBottom(child, headerHeight)
        return true // true 表示我們自己完成了解析 不要再自動(dòng)解析了
    }
}

正式開始嵌套滑動(dòng)的處理,先處理手指向上滑動(dòng)的情況。因?yàn)橹挥性?HeaderView 折疊后才允許 RecyclerView 滑動(dòng),因此要寫在 onNestedPreScroll 方法里。對(duì)這些滑動(dòng)回調(diào)不清楚的看看上面第二節(jié) NestingScroll 相關(guān)部分。

    override fun onStartNestedScroll(coordinatorLayout: CoordinatorLayout, child: View, directTargetChild: View,
                                     target: View, axes: Int, type: Int): Boolean {
        // 如果是垂直滑動(dòng)的話就聲明需要處理
        // 只有這里返回 true 才會(huì)收到下面一系列滑動(dòng)事件的回調(diào)
        return (axes and ViewCompat.SCROLL_AXIS_VERTICAL) != 0
    }

    override fun onNestedPreScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, dx: Int, dy: Int,
                                   consumed: IntArray, type: Int) {
        // 此時(shí) RecyclerView 還沒開始滑動(dòng)
        super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
        if (dy > 0) { // 只處理手指上滑
            val newTransY = child.translationY - dy
            if (newTransY >= -headerHeight) {
                // 完全消耗滑動(dòng)距離后沒有完全貼頂或剛好貼頂
                // 那么就聲明消耗所有滑動(dòng)距離,并上移 RecyclerView
                consumed[1] = dy // consumed[0/1] 分別用于聲明消耗了x/y方向多少滑動(dòng)距離
                child.translationY = newTransY
            } else {
                // 如果完全消耗那么會(huì)導(dǎo)致 RecyclerView 超出可視區(qū)域
                // 那么只消耗恰好讓 RecyclerView 貼頂?shù)木嚯x
                consumed[1] = headerHeight + child.translationY.toInt()
                child.translationY = -headerHeight.toFloat()
            }
        }
    }

并不復(fù)雜,核心思想是判斷 RecyclerView 在移動(dòng)用戶請(qǐng)求的距離后,會(huì)不會(huì)超出窗口區(qū)域。如果不超出那么就全部消耗,RV 自己不再滑動(dòng)。如果超出那么就只消耗不超出的那一部分,剩余距離由 RV 內(nèi)部滑動(dòng)。

接著寫手指向下滑動(dòng)的部分。因?yàn)檫@時(shí)候需要優(yōu)先讓 RecyclerView 滑動(dòng),在它滑動(dòng)到頂?shù)臅r(shí)候才需要整體下移讓 HeaderView 顯示出來,所以要在 onNestedScroll 里寫。

    override fun onNestedScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, dxConsumed: Int,
                                dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray) {
        // 此時(shí) RV 已經(jīng)完成了滑動(dòng),dyUnconsumed 表示剩余未消耗的滑動(dòng)距離
        super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                type, consumed)
        if (dyUnconsumed < 0) { // 只處理手指向下滑動(dòng)的情況
            val newTransY = child.translationY - dyUnconsumed
            if (newTransY <= 0) {
                child.= newTransY
            } else {
                child.translationY = 0f
            }
        }
    }

比上一個(gè)簡(jiǎn)單一些。如果滑動(dòng)后 RV 的偏移小于0(Y偏移<0代表向上移動(dòng))那么就表示還沒有完全歸位,那么消耗全部剩余距離。否則直接讓 RV 歸位就行了。

offsetTopAndBottom 與 translationY 的關(guān)系

從用途出發(fā),offsetTopAndBottom 常用于永久性修改,translationY 常用于臨時(shí)性修改(例如動(dòng)畫)這里我們也遵循了這個(gè)約定

從效果出發(fā),offsetTopAndBottom(offset) 是累加的,其內(nèi)部相當(dāng)于 mTop+=offset,而 translationY 每次都是重新設(shè)置與已有值無關(guān)。

最關(guān)鍵是,onLayoutChild 有可能被多次觸發(fā),因此動(dòng)畫所使用的方法必須與調(diào)整布局所使用的方法不同。否則有可能出現(xiàn)滑動(dòng)執(zhí)行到一半結(jié)果觸發(fā)了重新布局,結(jié)果自動(dòng)歸位,視覺上就是胡亂跳動(dòng)。

處理 HeaderView

接下來開始寫 HeaderView 的 Behavior 它的主要任務(wù)是監(jiān)聽 RecyclerView 的變化來改變 HeaderView 的屬性。

class NestedHeaderScrollBehavior constructor(context: Context?, attrs: AttributeSet?) :
        CoordinatorLayout.Behavior<View>(context, attrs) {

    override fun layoutDependsOn(parent: CoordinatorLayout, child: View, dependency: View): Boolean {
        // child: 當(dāng)前 Behavior 所關(guān)聯(lián)的 View,此處是 HeaderView
        // dependency: 待判斷是否需要監(jiān)聽的其他子 View
        return dependency.id == R.id.imagesRecyclerView
    }

    override fun onDependentViewChanged(parent: CoordinatorLayout, child: View, dependency: View): Boolean {
        child.translationY = dependency.translationY * 0.5f
        child.alpha = 1 + dependency.translationY / (child.height * 0.6f)
        // 如果改變了 child 的大小位置必須返回 true 來刷新
        return true
    }
}

這一個(gè)簡(jiǎn)單多了。layoutDependsOn 會(huì)對(duì)每一個(gè)子 View 觸發(fā)一遍,通過某種方法判斷是不是要監(jiān)聽的 View,只有這里返回了 true 才能收到對(duì)應(yīng) View 的后續(xù)回調(diào)。我們?cè)?onDependentViewChanged 中根據(jù) RecyclerView 的偏移量來計(jì)算 HeaderView 的偏于與透明度,通過乘以一個(gè)系數(shù)來實(shí)現(xiàn)視差移動(dòng)。

到此為止已經(jīng)基本上實(shí)現(xiàn)了上述效果。

Surprise! 自動(dòng)歸位

如果用戶拖動(dòng)到一半抬起了手指,讓 UI 停留在半折疊狀態(tài)是不合適的,應(yīng)當(dāng)根據(jù)具體位置自動(dòng)完全折疊或完全展開。

實(shí)現(xiàn)思路不難,監(jiān)聽停止滑動(dòng)事件,判斷當(dāng)前 RecyclerView 的偏移量,若超過一半就完全折疊否則就完全展開。這里需要借助 Scroller 實(shí)現(xiàn)動(dòng)畫。

Scroller 本質(zhì)上是個(gè)計(jì)算器,你只需告訴它起始值、變化量、持續(xù)時(shí)間,就可以幫你算出任意時(shí)刻應(yīng)該處于的位置,還可以定制不同緩動(dòng)效果。通過高頻率不斷地計(jì)算不斷地刷新不斷地移動(dòng)從而實(shí)現(xiàn)平滑動(dòng)畫。

OverScroller 包含了 Scroller 的全部功能并增加了額外功能,因此現(xiàn)在 Scroller 現(xiàn)在已被標(biāo)注為棄用。

我們來修改一下 RV 對(duì)應(yīng)的 NestedContentScrollBehavior.

    private lateinit var contentView: View // 其實(shí)就是 RecyclerView
    private var scroller: OverScroller? = null
    private val scrollRunnable = object : Runnable {
        override fun run() {
            scroller?.let { scroller ->
                if (scroller.computeScrollOffset()) {
                    contentView.translationY = scroller.currY.toFloat()
                    ViewCompat.postOnAnimation(contentView, this)
                }
            }
        }
    }

    override fun onLayoutChild(parent: CoordinatorLayout, child: View, layoutDirection: Int): Boolean {
        contentView = child
        // ...
    }

    private fun startAutoScroll(current: Int, target: Int, duration: Int) {
        if (scroller == null) {
            scroller = OverScroller(contentView.context)
        }
        if (scroller!!.isFinished) {
            contentView.removeCallbacks(scrollRunnable)
            scroller!!.startScroll(0, current, 0, target - current, duration)
            ViewCompat.postOnAnimation(contentView, scrollRunnable)
        }
    }

    private fun stopAutoScroll() {
        scroller?.let {
            if (!it.isFinished) {
                it.abortAnimation()
                contentView.removeCallbacks(scrollRunnable)
            }
        }
    }

首先定義三個(gè)變量并在合適的時(shí)候賦值。解釋一下 scrollRunnable,在得到不同時(shí)間應(yīng)該處于的不同位置后該怎么刷新 View 呢?因?yàn)榛瑒?dòng)事件已經(jīng)停止,我們得不到任何回調(diào)。王進(jìn)喜說 沒有條件就創(chuàng)造條件,這里通過 ViewCompat.postOnAnimation 讓 View 在下一次繪制時(shí)執(zhí)行定義好的 Runnable,在 Runnable 內(nèi)部改變 View 位置,如果動(dòng)畫還沒結(jié)束那么就再提交一個(gè) Runnable,于是實(shí)現(xiàn)了連續(xù)不斷的刷新。再寫兩個(gè)輔助函數(shù)便于開始和停止動(dòng)畫。

下面監(jiān)聽一下停止滑動(dòng)的回調(diào),根據(jù)情況來啟動(dòng)動(dòng)畫:

    override fun onStopNestedScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, type: Int) {
        super.onStopNestedScroll(coordinatorLayout, child, target, type)
        if (child.translationY >= 0f || child.translationY <= -headerHeight) {
            // RV 已經(jīng)歸位(完全折疊或完全展開)
            return
        }
        if (child.translationY <= -headerHeight * 0.5f) {
            stopAutoScroll()
            startAutoScroll(child.translationY.toInt(), -headerHeight, 1000)
        } else {
            stopAutoScroll()
            startAutoScroll(child.translationY.toInt(), 0, 600)
        }
    }

最后完善一下,開始滑動(dòng)時(shí)要停止動(dòng)畫,以免動(dòng)畫還沒結(jié)束用戶就迫不及待地又滑了一次:

    override fun onNestedPreScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, dx: Int, dy: Int,
                                   consumed: IntArray, type: Int) {
        super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
        stopAutoScroll()
        // ...
    }

    override fun onNestedScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, dxConsumed: Int,
                                dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray) {
        super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                type, consumed)
        stopAutoScroll()
        // ...
    }

到這就完美啦!恭喜??

文章每周持續(xù)更新,可以微信搜索「 程序猿養(yǎng)成中心 」第一時(shí)間閱讀和催更(比博客早一到兩篇喲),另外“點(diǎn)擊公眾號(hào)下方面試/更多資料”,直接免費(fèi)獲取一二線互聯(lián)網(wǎng)企業(yè)Android開發(fā)崗面試題匯總(答案解析)以及Android架構(gòu)知識(shí)點(diǎn)匯總pdf+超清Android進(jìn)階思維腦圖。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容