跑馬燈帶你深入淺出TextView的原始碼世界

vivo網際網路技術發表於2022-03-22

一、背景

想必大家平時也沒那麼多時間是單獨看原始碼,又或者只是單純的看原始碼遇到問題還是不知道怎麼從原始碼的角度解決。

但是大家平時開發過程中肯定會遇到這樣或那樣的小問題,通過百度、Google搜尋都無果,想嘗試分析原始碼又不知道從什麼地方開始分析起,導致最終放棄。

本篇文章就是通過一個小問題著手,從思路到實施一步步教大家面對一個問題時怎麼從原始碼的角度去分析解決問題。

1.1 問題背景

在Android6.0及以上系統版本中,點選“新增購物車”按鈕TextView跑馬燈動畫會出現跳動(動畫重置,滾動從頭重新開始)如下圖所示:

1.2 前期準備

下好原始碼的AndroidStuido 、生成一個Android模擬器、有問題的demo工程。

protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       setContentView(R.layout.activity_main);
       findViewById(R.id.show_tv).setSelected(true);
       final TextView changeTv = findViewById(R.id.change_tv);
       changeTv.setText(getString(R.string.shopping_count, mNum));
       findViewById(R.id.click_tv).setOnClickListener(new View.OnClickListener() {
           @Override
           public void onClick(View v) {
               mNum++;
               changeTv.setText(getString(R.string.shopping_count, mNum));
           }
       });
   }
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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"
    tools:context=".MainActivity">
 
    <com.workshop.textview.MyTextView
        android:id="@+id/show_tv"
        android:layout_width="match_parent"
        android:layout_height="40dp"
        android:layout_alignParentTop="true"
        android:layout_marginTop="30dp"
        android:ellipsize="marquee"
        android:focusable="true"
        android:focusableInTouchMode="true"
        android:marqueeRepeatLimit="marquee_forever"
        android:padding="5dp"
        android:scrollHorizontally="true"
        android:textColor="@android:color/holo_blue_bright"
        android:singleLine="true"
        android:text="!!!廣告!!!vivo S7手機將不懼距離與光線的限制,帶來全場景化自拍體驗,重新整理了5G時代的自拍旗艦標準"
        android:textSize="24sp" />
 
 
    <TextView
        android:id="@+id/change_tv"
        android:layout_width="wrap_content"
        android:layout_height="50dp"
        android:layout_centerHorizontal="true"
        android:layout_centerVertical="true"
        android:text="@string/shopping_count"
        android:textColor="@android:color/holo_orange_dark"
        android:textSize="28sp" />
 
    <TextView
        android:id="@+id/click_tv"
        android:layout_width="wrap_content"
        android:layout_height="40dp"
        android:layout_alignParentBottom="true"
        android:layout_centerHorizontal="true"
        android:layout_marginBottom="30dp"
        android:background="@android:color/darker_gray"
        android:padding="5dp"
        android:singleLine="true"
        android:text="新增購物車"
        android:textColor="@android:color/background_dark"
        android:textSize="24sp"
        android:textStyle="bold" />
 
</RelativeLayout>

二、思路

先說下解決問題的思路,個人也認為思路是本片文章比較重要的一個點。

  • 先去Google和百度上查詢textview跑馬燈的原理並最好能找到相關關鍵程式碼,如果沒有找到保底也要找到一個分析的切入點。

  • 畫出流程圖整理出整體的跑馬燈框架(如果只是想解決問題其實框架不用太細,不過這裡為了把事情說清楚,會將原理說的更深一點)。

  • 找到影響跑馬燈動畫變化的關鍵因素,對影響變數變化的原因做一個適當的猜想。

  • 用debug手段驗證自己的猜想。

  • 第四步和第五步持續的迴圈,最終找到自己的答案。

三、原始碼分析

3.1 跑馬燈整體流程分析

我也跟大部分人一樣,先Google一把,站在巨人的肩膀上,看看前人能不能給我一些思路,步驟如下;

1)開啟Google搜尋 “Android TextView 跑馬燈 原理” ;

2)隨便開啟幾個,這個時候我也不準備細看別人的分析,最好能找到框架圖,找不到就找到關鍵程式碼實現也是好的;

3)果然沒找到現成的框架圖,但是找到一篇文章裡提及了startMarquee()方法。看到這名字就知道靠譜,因為和我們xml裡面定義的定義的跑馬燈引數是一致的。 android:ellipsize="marquee" ;

4)在AndroidStdio裡搜尋TextView, 開啟類介面圖找到startMarquee()方法,這裡為了分析方便,我把方法貼到下面。

簡單分析一下這個程式碼;

做了一些是否跑馬燈的條件判斷。以第9,10行為例,只有當前設定的line為1,並且ellipsize屬性是marquee才進行初始化操作。我們知道只有在xml裡設定singleline ="true"同時設定ellipsize=“marquee”才能啟動跑馬燈,剛好和9,10行吻合。之後在23行執行start操作 start的具體內容會在後面分析。

5)確定找到的地方是正確的後,我們先不去研究細節,繼續瞭解整個框架的實現。

找一下這個方法用的地方,發現並不算多,有些地方都可以直接排除掉,這樣就可以畫出下面這個主流程圖。

  • 在onDraw()裡面的第一個方法就會根據屬性判斷是或否呼叫startMarquee()方法。

  • 在statMarquee()方法裡會初始化一個Textview的內部類Marquee()。

  • 初始化mMarquee後就呼叫.start()方法。

  • 這個方法裡會根據傳進來TextView物件,也就是它自己的一些屬性值,初始化一些跑馬燈所需要的資料值,以供父類使用。

  • 初始化值後呼叫TextView的invalidate()方法。

  • 之後會觸發onDraw()方法,onDraw()方法裡會根據mMarquee的屬性值進行移動畫布。

3.2 Marquee

第一節只是分析了大體的流程,但是我們看到TextView只是一個使用方,跑馬燈真正的業務實現是在一個叫做Marquee的內部類裡,還記得上面我們留了一個坑嗎,在startMarquee裡會呼叫mMarquee.start方法,這個時候就已經調到內部類裡面的方法了,我們來看看start方法裡都做了什麼。

2)第10行設定偏移變數為0.0f(1)第9行設定 mStatus為MARQUEE_STARTING,表示這是第一次滑動。

3)第11行設定文字的實際的寬度複製給textWidth,其實也很簡單,就是整個TextView控制元件的寬度減去左邊和右邊的padding區域。

4)第14行設定滑動的的間距gap,從這裡可以看出Android預設跑馬燈的滑動間距是文字長度的三分之一。

5)第16行設定最大滑動距離 mMaxScroll,其實也就是字的寬度加上gap。

6)第21行設定好所有初始變數後呼叫textView.invalidate();觸發textview的ondraw方法。這個也是我們平時最常用的觸發view重新整理的重新整理的方法,這個是在主執行緒重新整理所有隻要用invalidate就可以了。

7)第22行設定Choreographer監聽事件,用於後續繼續控制動畫。

簡單的畫一個TextView 和 TextView.Marquee 和Choreographer的關係圖。

TextView: 繪製跑馬燈的實體,主要在ondraw裡面初始化內部類TextView.Marquee。

TextView.Marquee:用來管理跑馬燈的偏向值onScroll,同時不停的呼叫invalidate方法觸發TextVIew的ondraw方法,用來繪製顯示文案。

Choreographer:系統的一個幀回撥方法,每一幀都會提供回撥給Marquee用於觸發view的重新整理,保證動畫的平滑性,後面會詳細說下Choreographer。

3.3 Choreographer

Choreographer是一個系統的方法,我們先來看下它在Google官方的定義是什麼;

Coordinates the timing of animations, input and drawing.......Applications typically interact with the choreographer indirectly using higher level abstractions in the animation framework or the view hierarchy. Here are some examples of things you can do using the higher-level APIs.

翻譯過來就是:這個類是一個監聽系統的垂直幀訊號,在每一幀都會回撥。它是一個底層api,如果你是在做Animation之類的事情,請使用更高階的api。

理解一下:就是一般不建議你用,我猜想可能是因為它回撥過於頻繁,可能會影響效能。它的回撥次數也跟當前手機螢幕的重新整理率有關,對於一個60重新整理率的系統來說 這個postFrameCallback會在1000/60 = 16.6毫秒回撥一次,如果是120重新整理率的話就是1000/120 = 8.3毫秒就回撥一次。所以在綜上所述,這個類的回撥不能做耗時的工作。

簡單看下choreographer的實現原理,裡面會監聽一個叫做DisplayEventReceiver的系統Receiver,這個Receiver會跟底層的SurfaceFlinger 的 Connection 連線,SurfaceFlinger會實時發sync訊號,通過onVsync回撥上來。

重點我們來看看Marquee在postFrameCallback裡做了哪些事情;在Choreographer裡面會呼叫一個叫做Tick的方法,就是用來計算偏向值的,我們對這個方法來深入分析下。

1)前3行定義了mPixelsPerMs 看著是不是很熟悉,其實就是定義了滑動的速度,30dp對應的px值/1000ms。也就是android 跑馬燈預設的滑動速度是30dp每秒。

2)第16行,通過回撥的當前時間currentMs和上一次回撥的時間mLastAnimationMs 算出差值deltaMs 這裡的單位是ms。

3)第18行,通過deltaMs和mPixelsPerMs 算出當前時間差所要移動的位移,複製給mScroll。

4)第20行,如果位移大於最大值,就等於最大值。

5)第26行,呼叫了invalidate重新整理TextView。

既然前面初始化了mMarquee並且重新整理了Textview,接下來TextView的ondraw肯定是要用到mMarquess裡面的資料進行繪製,ondraw的方法比較長,這裡我們找到了兩處使用mMarquee的地方,分別是;

分別對兩個地方都打上斷點,發現只走了程式碼段二,那麼我們重點來看看程式碼二里面做了什麼(在通過程式碼已經搞不清路徑的情況下,通過debug是最好的方式)。可以看到程式碼二里面是根據getScroll()值,對畫布做了水平移動,不停的回撥移動,也就形成了動畫。

總結一下,算出時間差值(currentMs - mLastAnimationMs),再用這個時間差值乘以30dp複製給mScroll. 也就是每秒移動30dp,最後再主動觸發TextView的重新整理。通過postFrameCallback不僅解決了持續觸發跑馬燈動畫的問題,還保證了動畫了流暢性。

我們給第二部分做一個結論:TextView通過:marquee → Choreographer → mScroll 最終在ondraw裡面繪製TextView的位置。

知道原理後我們接下來回到問題的本身,分析問題。

四、問題分析

通過第二節的原理分析後,在結合視訊裡面現象,我們知道動畫發生了重置了,必然是mScroll發生了變化。

4.1 誰引發mScroller重置

再結合整個現象,可以猜測在點選"新增購物車"按鈕後,某段程式碼重置了getScroll()值,也就是Marquee的成員變數mScroll。

有了猜測,順著這個思路,我們來找找哪些地方把mScroll置為零了。通過debug向上追到頭,發現是有人觸發了TextView的onMeasure方法。

4.2 誰觸發的onMeasure

1)在view初始化的時候會走一遍完整的生命週期,如下圖所示;

2)在呼叫requestLayout()的方法,會觸發onMeasure。

並且當子view觸發requestLayout的時候,會觸發整個檢視樹的重繪,這個時候ViewGroup除了要完成自己的measure過程,還會遍歷呼叫所有子元素的measure方法。以framelayout為例;

在第35行會遍歷並觸發所有子view的measure方法。基於以上的2個事實我們提出以下一個假設。

子view A 呼叫了requestLayout方法,viewgroup發生了重繪,觸發了子view B的 onMeasure()方法 。

那麼目標就很明確了,視訊裡另外一個顯示數字增加的子view和它唯一做的一件事setText。

4.3 怎麼觸發onMeasure的

前面的猜想就是我們可能是在setText裡面觸發了requestLayout方法,那麼想驗證就簡單了:

  • 在setText的入口方法打上斷點 ;

  • 在所有呼叫requestLayout的地方都打上斷點。

果然不出所料,沿著setText方法debug下去有呼叫requestLayout方法,這個時候嘗試畫出流程圖。

去掉所有其他邏輯,我們發現它會判斷當前佈局方式是wrap_content去執行不一樣的邏輯。看了下“購物車”按鈕就是wrap_content屬性,所以會走requestLayout,繼而會觸發跑馬燈的重繪。

五、問題解決

通過問題分析的結論,那麼解決方案就顯而易見了,把“購物車”按鈕的屬性改成非wrap_content再次嘗試,果然跑馬燈就不會再次重繪了,修改程式碼如下:

六、總結

經過此次分析我們來以迷宮為例子總結一下收穫:

對於原始碼現象的分析需要依賴自己對Android知識的熟練掌握,並精準的猜想作為前提。Android知識更像是走迷宮的指南針。

debug可以作為排除一些錯誤的支線,直接找到正確的主線,更像是在迷宮裡加上幾個錨點,進行試錯。

多畫流程圖可以加深自己的框架的理解,流程圖更像是迷宮的地圖,幫助你少走彎路。

作者:vivo官網商城開發團隊-HouYutao

相關文章