本文發(fā)于簡書——何時夕,搬運轉載請注明出處,否則將追究版權責任。交流qq群:859640274
GitHub地址
庫依賴: implementation 'com.whensunset:sticker:0.2'
近兩個月沒有更新博客了,感覺已經(jīng)過氣了,哈哈。其實我在準備一個大招,而這個大招準備時間比較長,大家好好期待吧。本篇文章算是大招的前菜,來填補一下這么久沒有更新的間隙。當然本篇文章也不是水水而過的,里面的干貨非常多,因為我最近幾個月的工作內(nèi)容就和這個相關——story 的文字、貼紙控件。
閱讀須知:
- 1.文字、普通貼紙、動態(tài)貼紙等等統(tǒng)稱為——元素
- 2.后面會有一些英文縮寫:TextureView——TV、RenderThread——RT、ViewGroup——VG、Instagram——ins、ElementContainerView——ECV、DecorationElementContainerView——DECV、ElementActionListener——EAL、WsElement——WE、RLECV——RuleLineElementContainerView、TECV——TrashElementContainerView
- 3.抖音、多閃——抖閃
本文分為以下章節(jié),讀者可按需閱讀:
- 1.story產(chǎn)品技術分析——聊一聊市面上可以發(fā)布 story 的 app 的功能以及可能的技術實現(xiàn)。
- 2.Android端貼紙文字架構與實現(xiàn)——講一講如何實現(xiàn)一個集各家之長的 android 端文字貼紙功能。
- 3.仿寫一個抖音貼紙控件——基于2中的核心代碼,簡單實現(xiàn)抖音 app 中的貼紙控件。
一、Story產(chǎn)品技術分析
首先市面上有很多 app 都支持 story 以及類似概念的視頻的拍攝和發(fā)布。國外的 story 鼻祖是 Ins。國內(nèi)的微信的時刻視頻、多閃的視頻拍攝、抖音的隨拍等等,都是借鑒了 Ins 的 story。本章的分析也是建立在對上面的四款 app 的分析之上。
1.產(chǎn)品功能分析
下表是我仔細把玩了中外比較有名的可以發(fā)布 story 視頻的 app 之后的出的結論,下面我們來根據(jù)各個產(chǎn)品的功能仔細分析一下。
| 抖音 | 多閃 | 微信 | ||
|---|---|---|---|---|
| 文字 | 有、功能最豐富 | 有、功能比較豐富 | 有、功能比較少 | 有、功能最少 |
| 文字放大 | 有 emoji 時模糊、無則清晰、放大不卡頓 | 不模糊、放大卡頓 | 不模糊、放大卡頓 | 有點模糊、放大不卡頓 |
| 動態(tài)貼紙 | 有、只支持gif、跟手 | 有、支持視頻格式、不跟手 | 有、支持視頻格式、不跟手 | 有、只支持 gif、跟手 |
| 功能貼紙 | 有、功能豐富 | 有、功能一般 | 有、功能一般 | 有、只有地理位置貼紙 |
| 普通貼紙 | 有、非常跟手 | 有、不跟手 | 有、不跟手 | 有、非常跟手 |
| 文字、貼紙是否可相互覆蓋 | 可以 | 不可以 | 不可以 | 可以 |
- 1.首先 Ins 算是無冕之王了,畢竟 story 這個概念是就是 ins 帶火的。可以說 ins 的功能最全最精細,如果我們要找個標桿的話那么非 ins 莫屬。
- 2.從上面的圖我們發(fā)現(xiàn),抖音、閃多的功能非常類似,畢竟是父子關系,所以這兩家我們可以當成一家分析(后稱抖閃)。在我體驗的過程中抖閃有一個體驗點(表中沒有列出)是超過了 ins 的。那就是文字編輯狀態(tài)切換的流暢程度,抖閃使用了流暢的過渡動畫,ins 則是生硬的出現(xiàn)和消失。這里在我看來就有點東西了,至于是啥東西我會在后面技術分析的時候點出。
- 3.這樣看來微信似乎有種迷之自信,無論是功能還是體驗其實都比不上其他三位玩家,但是唯一值得稱贊的點就是微信的貼紙能夠使用我們平時聊天沉淀下來的表情包。這個算不算是一種降維打擊就交給讀者去評判了。
- 4.再來看看貼紙的跟手問題與文字貼紙是否可以相互覆蓋的問題。
- 1.我們發(fā)現(xiàn)如果貼紙只支持 gif,就會跟手。如果貼紙支持視頻格式,就會不跟手。
- 2.同樣如果貼紙支持 gif,文字和貼紙就可以相互覆蓋,反之則不能相互覆蓋。
- 3.上面提到的兩個問題我也會在后面的技術分析的時候點出答案。
- 5.最后一個問題就是文字放大模糊與卡頓的問題。微信文字放大之后都會出現(xiàn)有點模糊現(xiàn)象,而抖閃則不會(這里指的是編輯的視頻而不是發(fā)布后的視頻)。微信使用了一個非常雞賊的方式使得文字最終并不會很模糊,那就是限制文字的放大倍數(shù),而且文字不允許調節(jié)字體大小。而抖閃有個問題就是文字含有多個 emoji 的時候放大會非??D且會閃爍,微信則沒有這個問題無論多少 emoji 縮放都非常流暢。ins 則是個特例,他有 emoji 的時候放大會模糊,無 emoji 的時候放大不模糊,而且放大始終不卡頓。這個問題我也會在技術分析的時候詳細解釋。
2.技術分析
一個功能的誕生過程就是產(chǎn)品和技術相互妥協(xié)(撕逼)的過程。所以這一節(jié)我就來聊聊上一節(jié)中分析的四個 app 體驗上達不到盡善盡美的技術原因,也為我們后面的技術實現(xiàn)排坑。
(1).TextureView(SurfaceView)與ViewGroup之爭
關注我的同學應該知道我上一篇博客發(fā)表的是 SurfaceView家族源碼全解析。當我知道要做這個需求的時候其實我第一個想到的是用 TV。因為無論文字也好、貼紙也罷都能被繪制到 Surface 上面,而且性能似乎也不會很差。但是最終的結果是我多加了幾天班完全重構了使用 TV 作為基礎繪制容器的代碼。千言萬語匯成一首詩:代碼千萬行,思考第一行。架構拎不清,加班到天明。那么下面我就來講講 TV 和 VG 作為基礎繪制容器的優(yōu)劣勢:
- 1.TV 的優(yōu)勢:
- 1.繪制邏輯清晰,可以手動控制繪制流程。
- 2.似乎沒了。。。
- 2.VG 的優(yōu)勢:
- 1.有大量的現(xiàn)成控件可以進行組合,這些組合基本上可以滿足我們的所有需求。可以方便功能貼紙的開發(fā)。
- 2.有整套的事件分發(fā)流程可以使用,方便 元素 響應事件。
- 3.在已經(jīng)有一個 TV 的情況下(例如編輯的時候視頻使用 TV 播放),VG 的刷新對 RT 的影響很小。而 TV 則會增大 RT 的負載。這里的直觀體驗就是:縮放移動元素的時候,視頻播放會非常卡頓,原因就是我們的 TV 刷新?lián)屨剂艘曨l播放的 TV 的 cpu 時間(這也是我最終放棄 TV 的原因)
- 4.使用 VG 我們就可以使用各種各樣的動畫來優(yōu)化用戶體驗,讓 元素 的狀態(tài)切換非常順滑。例子就是 抖閃 文字編輯狀態(tài)切換的動畫。
- 5.不用我們自己用 canvas 寫各種各樣的繪制邏輯了。那些代碼寫的時候我和上帝都能看懂,但是幾個月之后就只有上帝能看懂了。用知乎的話來說,這種代碼就是——屎山。
- 3.其實我們比較了這么多發(fā)現(xiàn),VG 的大部分好處都是 android 的 framework 層給的。如果我們用 TV 來實現(xiàn)的話,只是重新造一個漏洞百出的輪子。從工期、用戶體驗、代碼擴展性等等各個方面的比較來看 VG 都是完爆 TV 的。請原諒兩個月前的我做出了選擇 TV 這個愚蠢的選擇。親愛的讀者如果你覺得我?guī)湍阚忂^了這個大坑,那么就快點關注我的微信公眾號:世界上有意思的事。干貨多多等你來看。
(2).如何顯示動態(tài)貼紙
由前面的對比我們知道,是否支持視頻格式的資源與是否跟手有著不可調和的矛盾。ins 和 微信選擇了跟手,抖閃則選擇了支持視頻格式資源。接下來我們就來分析這里面的技術原理與取舍原因
- 1.首先我們得知道為了支持多個具有視頻資源的動態(tài)貼紙的顯示而在 framework 層展示多個視頻播放窗口是非常愚蠢的行為。因為一般來說我們的背景就是視頻播放器,我們完全可以通過 native 層的能力將多個動態(tài)貼紙的視頻資源整合到視頻播放器中。也就是說始終只有一個視頻播放器,動態(tài)貼紙的資源交給播放器去播放。當然這樣的視頻播放器即使有開源的也需要根據(jù)自己的功能進行相應的裁剪。四個 app 中抖閃是選擇了這種方案,我們可以簡單的判斷這種播放器所具有的能力:
- 1.能夠播放普通的視頻(這個是廢話)
- 2.能夠對視頻進行位移、縮放、旋轉這類的操作
- 3.播放器能在播放視頻的情況下添加多個子視頻,且子視頻也支持位移、旋轉、縮放等等功能。
- 4.子視頻的各種信息可以在主視頻播放的過程中進行實時變化,重要的是性能不可以太差,像抖閃目前這種狀況算是在用戶不可接受的邊緣試探吧。
- 2.ins 和 微信都選擇了跟手,那么顯而易見他們的實現(xiàn)方式就是在 framework 層將 gif/webp 的資源顯示在 view 上,那么跟手也就是理所當然的事了。至于啥控件能顯示 gif 和 webp 的圖呢?那當然是 Fresco 啦,剛好也是 FaceBook 出品。
- 3.現(xiàn)在我們知道其實支持視頻格式的資源比 gif 要難上很多,只支持 gif 的話我能夠獨立做出這個功能來。一旦支持視頻格式那么光我一個人目前來說是搞不定的(當然后面我們的視頻編輯 sdk 開發(fā)完成之后我應該就能搞定了)。那么支持視頻格式的資源有什么好處呢?下面我來列舉一下
- 1.能夠精細的控制動態(tài)貼紙的顯示范圍,因為 framework 層的 gif 我們是控制不了的。而如果是視頻資源的話 native 層可以控制視頻的進度,播放區(qū)域等等屬性。
- 2.視頻格式比 gif 更具拓展性,展示畫面的精細程度也更高。
- 4.其實抖閃的實現(xiàn)方式還會有一個缺點就是:文字、貼紙不能相互覆蓋了,因為貼紙始終是被渲染在視頻中的,文字則是用 view 的方式來顯示。貼紙在 z 軸上永遠都會在文字的下方。
(3).文字的顯示方式之爭
如果讀者看透了(1)和(2)的話,那么我相信你的心里已經(jīng)非常清楚四種 app 都是采取什么樣的方式來顯示文字的。我這里也就簡單分析一下:
- 1.毋庸置疑四種 app 都是使用了 VG 來當做基礎繪制容器。ins 和 微信因為支持 gif,不用說肯定是用 view 來展示 gif 的。而抖閃雖然貼紙都是交給播放器渲染的,但是他們有各種功能貼紙,這些貼紙的組合也只能是使用 view 來組合。要不然代碼真的沒法維護了,對于這種代碼我親身體會過。
- 2.那么現(xiàn)在問題就來了同樣是使用 view 來展示文字,為啥抖閃、ins、微信的最終表現(xiàn)卻各不相同呢?這里的一個關鍵點就是:view 的種類。
- 1.我們首先可以確認的是微信在文字編輯完成之后,會獲取 EditText 的 view 截圖,最終在界面上縮放位移旋轉的是一個類 ImageView,這就解釋了文字放大模糊的現(xiàn)象。而微信“巧妙”的限制了文字縮放的倍數(shù),這樣就讓用戶最終不會覺得文字很糊。
- 2.抖閃是一家,所以他們展示文字的方式如出一轍,使用 EditText 來展示編輯完成的文字。也就是說在界面上縮放旋轉的 view 還是 EditText。這樣的好處顯而易見,用戶就算把文字放的很大顯示出來還是非常清晰。但是我前面也說了這樣的方案有一個缺陷就是:EditText 在有比較多的 emoji 且放大倍數(shù)比較大時,操作會非??D,時而還有閃屏的現(xiàn)象。這個應該是 EditText 本身的 bug,感覺 google 自家如果不解這個 bug 的話,就要一直留著了。
- 3.ins 結合了這兩種方案。在有 emoji 的情況下蛻化成了使用 ImageView 來顯示文字的截圖,沒有 emoji 的情況下則使用 EditText 來顯示文字。這也是將 ins 稱為無冕之王的一個原因,它照顧到了各種用戶體驗細節(jié),盡力給用戶最好的體驗。不過最終四家誰的方案最好就交給讀者和用戶去評判了。
- 4.我前面說了抖閃在文字編輯狀態(tài)的切換上比 ins 做得好,因為他們用上了動畫來切換。微信因為使用的 ImageView 來展示文字截圖不好做這個動畫可以理解。但是 ins 應該有做這個動畫的方法的,個人感覺可能是為了讓用戶在有無 emoji 時體驗一致而沒有做這個動畫吧。
(4).View的縮放位移之爭
我們都知道 android 中讓 view 變化大小和位置有兩種方式,一個是改變 view LayoutParam 中的真實屬性,一個是設置 view 的 scale 和 translation。下面我們就來講講這兩種方式的特點,當然最終我們的實現(xiàn)方案中兩種都會有
- 1.改變 LayoutParam 來改變 view 的特點:
- 1.view 中的內(nèi)容始終是最初定義的大小,例如 view 中有文字那么文字的字體大小不會改變。
- 2.view 如果是一個 VG 的話那么它會重新進行布局。
- 3.能夠比較方便的進行事件分發(fā),比如我現(xiàn)在的實現(xiàn)中在這種模式下就能夠進行準確的事件分發(fā)。
- 2.使用 scale 和 translation 來改變 view 的特點:
- 1.view 中的內(nèi)容能夠直接放大和縮小,這個特性適合我們的絕大多數(shù)需求場景。
- 2.view 不會重新進行 measure、layout 和 draw。性能上似乎比前一種方式好一點。
- 3.也能夠進行事件分發(fā),但是應該有點坑,目前我在這種模式下實現(xiàn)不了準確的事件分發(fā),可能是我的實現(xiàn)有問題。
二、Android端貼紙文字控件架構與實現(xiàn)
1.架構方式
我們第一節(jié)先講講文字貼紙控件的架構實現(xiàn),我會基于下面的 圖1 和 github 上的代碼進行講解。建議大家把代碼 clone 下來,當然別忘了給個 star。

我們先來根據(jù)圖1來講講整個控件的架構
- 1.我們先從整體來看:
- 1.我們在前一章分析了整個控件的繪制容器應該是一個 VG。所以圖中的 ElementContainerView 就是這樣一個容器,簡單概括一下它有這些功能:
- 1.處理各種手勢事件,這里的手勢包括單指和雙指。
- 2.添加和刪除一些 view。這里的 view 用于繪制各種元素。
- 3.提供一些 api 讓外部能夠操控 view。
- 4.提供一個 listener,讓外部能夠監(jiān)聽內(nèi)部的流程。
- 2.有了繪制容器,我們需要向繪制容器里面添加 view。而 view 在用戶操作的過程中需要有各種數(shù)據(jù),所以這里我用了 WE 來封裝 需要展示的view,其內(nèi)部有下面這些東西:
- 1.各種用戶操作過程中需要的數(shù)據(jù)例如:scale、rotate、x、y等等。
- 2.有一些方法能夠通過數(shù)據(jù)來更新 view。
- 3.提供一些 api 讓 ECV 能操縱 WE 里面的 view。
- 3.由 ECV 和 WE 就能繼續(xù)繼承出各種各樣的擴展控件。
- 1.我們在前一章分析了整個控件的繪制容器應該是一個 VG。所以圖中的 ElementContainerView 就是這樣一個容器,簡單概括一下它有這些功能:
- 2.整體講完了,我們就可以來仔細的講講圖中的流程
- 1.先講橫著的箭頭:外部/內(nèi)部調用,外部需要調用 ECV 來進行對 WE 的增刪改查等操作時會進入這個路徑,這個路徑里可以有下面這些操作:
- 1.addElement:向 ECV 中添加一個元素。
- 2.deleteElement:從 ECV 中刪除一個元素。
- 3.update:讓 WE 中的 view 根據(jù)當前數(shù)據(jù)刷新狀態(tài)。
- 4.findElementByPosition:找到傳入的坐標下的最頂層的 WE。
- 5.selectElement:選中一個 WE 且將其調到最頂層。
- 6.unSelectElement:取消選中一個 WE。
- 2.再來講豎著的箭頭:手勢事件流,這里中間會經(jīng)歷一些內(nèi)部邏輯我們后面來講,最終事件流會觸發(fā)下面的一系列行為:
- 1.單指移動的整個流程:當我們選中了一個 WE 的時候就可以對它進行移動。這里移動可以分為開始、進行中、結束。每個事件都會調用 WE 的對應方法以更新其內(nèi)部的數(shù)據(jù)然后更新 view。
- 2.雙指旋轉縮放的整個流程:當我們選中了一個 WE 的時候可以用雙指對它進行縮放和旋轉。這里可以分為開始、進行中、結束。這里也會調用 WE 的對應方法更新數(shù)據(jù)然后更新 view。
- 3.選中元素再次點擊:當我們選中了一個 WE 的時候,可以對其再次點擊。因為 WE 表示的是一個 view,所以我們可以直接將事件交給 view 觸發(fā)其內(nèi)部的各種響應。當然我們也可以添加一個 VG 來作為一個 WE 的繪制 view。此時我們可以把點擊事件交給 VG,它還可以繼續(xù)將事件分發(fā)給子 view。注意:因為 ECV 需要接收移動事件,所以目前只有點擊事件能夠被分發(fā)。
- 4.點擊空白區(qū)域:當我們沒有點擊任意 WE 的時候可以進行一些操作,例如清除當前 WE 的選中狀態(tài)。這個行為是可以繼承的,可以交由子類來覆寫。
- 5.onFling:這是一個“拋”的手勢,可以用來實現(xiàn)一些好玩的行為,例如手指抬起的時候讓 WE 再滑動一段距離。這個行為也是可繼承的,可以交由子類覆寫。
- 6.子類事件:我們看上面其實感覺觸發(fā)的事件比較少。所以在 down、move、up 的時候會優(yōu)先調用三個方法 downSelectTapOtherAction、scrollSelectTapOtherAction、upSelectTapOtherAction。這三個方法可以被子類覆寫,如果返回 true 的話表示事件已經(jīng)消耗了,ECV 就不會再觸發(fā)其他事件。這樣一來子類也可以對手勢進行擴展,例如按住某個地方單指縮放等等。
- 7.我圖中 ECV 也實現(xiàn)了一個子類 DECV,這個類簡單的加兩個手勢:
- 1.單指移動縮放:類似抖音的隨拍,按住元素的右下角的時候可以用拖動來對元素進行縮放和旋轉。
- 2.刪除:類似抖音的隨拍,點擊元素左上角的時候可以直接刪除元素。
- 3.圖1中有一個特性其實沒有畫出來因為畫不下了,那就是:ECV 在1和2中的幾乎所有行為都能被外部監(jiān)聽,ElementActionListener 就是負責監(jiān)聽的接口。ECV 中存有一個 EAL 的 set 集合所以監(jiān)聽器可以添加多個。
- 1.先講橫著的箭頭:外部/內(nèi)部調用,外部需要調用 ECV 來進行對 WE 的增刪改查等操作時會進入這個路徑,這個路徑里可以有下面這些操作:
2.技術點實現(xiàn)
我在開發(fā)整個控件的時候遇到過比較多的技術實現(xiàn)上的難點,所以這一節(jié)就選一些來講講,讓讀者在看源碼的時候不會特別困惑。
(1).定義數(shù)據(jù)結構與繪制坐標系
-----代碼塊1----- com.whensunset.sticker.WsElement
public int mZIndex = -1; // 圖像的層級
protected float mMoveX; // 初始化后相對 mElementContainerView 中心 的移動距離
protected float mMoveY; // 初始化后相對 mElementContainerView 中心 的移動距離
protected float mOriginWidth; // 初始化時內(nèi)容的寬度
protected float mOriginHeight; // 初始化時內(nèi)容的高度
protected Rect mEditRect; // 可繪制的區(qū)域
protected float mRotate; // 圖像順時針旋轉的角度
protected float mScale = 1.0f; // 圖像縮放的大小
protected float mAlpha = 1.0f; // 圖像的透明度
protected boolean mIsSelected; // 是否處于選中狀態(tài)
@ElementType
protected int mElementType; // 用于區(qū)別元素種類
// Element 中 mElementShowingView 的父 View,用于包容所有的 Element 需要顯示的 view
protected ElementContainerView mElementContainerView;
protected View mElementShowingView; // 用于展示內(nèi)容的 view
protected int mRedundantAreaLeftRight = 0; // 內(nèi)容區(qū)域左右向外延伸的一段距離,用于擴展元素的可點擊區(qū)域
protected int mRedundantAreaTopBottom = 0; // 內(nèi)容區(qū)域上下向外延伸的一段距離,用于擴展元素的可點擊區(qū)域
// 是否讓 showing view 響應選中該 元素 之后的點擊事件
protected boolean mIsResponseSelectedClick = false;
// 是否在刷新 showing view 的時候,真正修改 height、width 之類的參數(shù)。一般來說只是使用 scale 和 rotate 來刷新 view
protected boolean mIsRealUpdateShowingViewParams = false;
函數(shù)未動數(shù)據(jù)先行,數(shù)據(jù)結構是一個框架非常核心的東西,定義了一個好的數(shù)據(jù)結構可以省去很多不必要的代碼。所以這一小節(jié)我們來根據(jù)代碼塊1定義一下數(shù)據(jù)結構和 view 繪制坐標系
1.我們將 WE 所在的 ECV 作為 WE 中 view 的可繪制區(qū)域,代碼塊1中的 mEditRect 就是這個區(qū)域代表的矩形。所以 mEditRect 一般為[0, 0, ECV.getWidth, ECV.getHeight],mEditRect 的單位為px。
2.我們定義的坐標系原點在 mEditRect 的中心點,也就是 ECV 的中心點。mMoveX、mMoveY 分別表示 view 距離坐標系原點的距離。因為它們倆默認為 0,所以一般 view 被添加到 ECV 中的時候默認位置就在 ECV 的中心。這兩個參數(shù)的單位為px。
3.我們的坐標系具有 z 軸,mZIndex 就是 z 軸的坐標,z 軸表示 view 的層疊關系,mZIndex 為 0 時表示 view 在 ECV 的頂層。mZindex 默認為 -1,表示 view 沒有被添加到 ECV 中。mZIndex 是整數(shù)。
-
4.我們定義 mRotate 為正時 view 順時針轉動,mRotate 的區(qū)間為[-360,360]。
5.我們定義 view 沒有縮放的時候 mScale 為 1,mScale 為 2 的時候表示 view 放大 2 倍,以此類推。
6.mOriginWidth 和 mOriginHeight 為 view 的初始大小,單位是px。
7.mAlpha 為 view 的透明度,默認為 1 且小于等于1。
8.剩下的參數(shù)就不用解釋了,代碼里面都有注釋。
(2).WE中的View是如何更新的
從前面的分析我們知道了在 ECV 處理手勢的過程中會不斷更新 WE 中的各種數(shù)據(jù),更新完了數(shù)據(jù)之后會調用 WE.update 來刷新 view的狀態(tài)。我們就來通過代碼塊2來簡單分析一下我們支持的兩種 view 的刷新方式:
-----代碼塊2----- com.whensunset.sticker.WsElement#update
public void update() {
if (isRealChangeShowingView()) {
AbsoluteLayout.LayoutParams showingViewLayoutParams = (AbsoluteLayout.LayoutParams) mElementShowingView.getLayoutParams();
showingViewLayoutParams.width = (int) (mOriginWidth * mScale);
showingViewLayoutParams.height = (int) (mOriginHeight * mScale);
if (!limitElementAreaLeftRight()) {
mMoveX = (mMoveX < 0 ? -1 * getLeftRightLimitLength() : getLeftRightLimitLength());
}
showingViewLayoutParams.x = (int) getRealX(mMoveX, mElementShowingView);
if (!limitElementAreaTopBottom()) {
mMoveY = (mMoveY < 0 ? -1 * getBottomTopLimitLength() : getBottomTopLimitLength());
}
showingViewLayoutParams.y = (int) getRealY(mMoveY, mElementShowingView);
mElementShowingView.setLayoutParams(showingViewLayoutParams);
} else {
mElementShowingView.setScaleX(mScale);
mElementShowingView.setScaleY(mScale);
if (!limitElementAreaLeftRight()) {
mMoveX = (mMoveX < 0 ? -1 * getLeftRightLimitLength() : getLeftRightLimitLength());
}
mElementShowingView.setTranslationX(getRealX(mMoveX, mElementShowingView));
if (!limitElementAreaTopBottom()) {
mMoveY = (mMoveY < 0 ? -1 * getBottomTopLimitLength() : getBottomTopLimitLength());
}
mElementShowingView.setTranslationY(getRealY(mMoveY, mElementShowingView));
}
mElementShowingView.setRotation(mRotate);
mElementShowingView.bringToFront();
}
- 1.設置 view 的真實參數(shù)來更新 view:代碼塊2中我們看見有一個 flag 來區(qū)分兩種 view 的更新方式。本方式也非常簡單,因為我們的 ECV 是繼承于 AbsoluteLayout 的所以先獲取 mElementShowingView 的 LayoutParam 然后再將相應的數(shù)據(jù)設置進去就行了。這里有兩個要注意的地方:
- 1.這種方式每次都會重新 measure、layout、draw
- 2.這種方式目前我已經(jīng)成功實現(xiàn)了讓 view 在為 VG 的時候進行事件分發(fā)。
- 2.設置 view 的畫布參數(shù)來更新 view:第二種方式是通過設置 view 在底層的 RenderNode 的參數(shù)來更新 view。我們其實可以簡單的類比為對 canvas 做 scale、rotate、translate。這種方式有兩個需要注意的地方:
- 1.這種方式不會更新 measure、layout、draw 等方法,性能應該比1號。
- 2.這種方式目前只能在為 view 的時候響應事件,如果 view 為 VG 那么事件將會錯亂,暫時還沒有好的解決方案。
- 3.上面兩種 view 更新方式有著一些共同點:
- 1.我們都對 view 的 mMoveX、mMoveY 進行了一個限制,如果當前的數(shù)據(jù)超過了限制就將這兩個參數(shù)設置為上下限值。
- 2.都使用 setRotation 來讓 view 實現(xiàn)旋轉
- 3.更新結束的時候需要 bringToFront 將 view 提到 ECV 的頂層。
(3).事件是如何從ECV交給子VG進行分發(fā)的
首先 android 的事件分發(fā)體系我就不贅述了,網(wǎng)上已經(jīng)有很多資料了。我下面會結合代碼塊3講講具體的實現(xiàn)方案
-----代碼塊3----- com.whensunset.sticker.ElementContainerView
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (mSelectedElement != null && mSelectedElement.isShowingViewResponseSelectedClick()) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
long time = System.currentTimeMillis();
mUpDownMotionEvent[0] = copyMotionEvent(ev);
Log.i(DEBUG_TAG, "time:" + (System.currentTimeMillis() - time));
} else if (ev.getAction() == MotionEvent.ACTION_UP) {
mUpDownMotionEvent[1] = copyMotionEvent(ev);
}
}
return super.dispatchTouchEvent(ev);
}
private static MotionEvent copyMotionEvent(MotionEvent motionEvent) {
Class<?> c = MotionEvent.class;
Method motionEventMethod = null;
try {
motionEventMethod = c.getMethod("copy");
} catch (NoSuchMethodException e) {
e.printStackTrace();
}
MotionEvent copyMotionEvent = null;
try {
copyMotionEvent = (MotionEvent) motionEventMethod.invoke(motionEvent);
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
return copyMotionEvent;
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
return true;
}
/**
* 選中之后再次點擊選中的元素
*/
protected void selectedClick(MotionEvent e) {
if (mSelectedElement == null) {
Log.w(DEBUG_TAG, "selectedClick edit text but not select ");
} else {
if (mSelectedElement.isShowingViewResponseSelectedClick()) {
mUpDownMotionEvent[0].setLocation(
mUpDownMotionEvent[0].getX() - mSelectedElement.mElementShowingView.getLeft(),
mUpDownMotionEvent[0].getY() - mSelectedElement.mElementShowingView.getTop());
rotateMotionEvent(mUpDownMotionEvent[0], mSelectedElement);
mUpDownMotionEvent[1].setLocation(
mUpDownMotionEvent[1].getX() - mSelectedElement.mElementShowingView.getLeft(),
mUpDownMotionEvent[1].getY() - mSelectedElement.mElementShowingView.getTop());
rotateMotionEvent(mUpDownMotionEvent[1], mSelectedElement);
mSelectedElement.mElementShowingView.dispatchTouchEvent(mUpDownMotionEvent[0]);
mSelectedElement.mElementShowingView.dispatchTouchEvent(mUpDownMotionEvent[1]);
} else {
mSelectedElement.selectedClick(e);
}
callListener(
elementActionListener -> elementActionListener
.onSelectedClick(mSelectedElement));
}
}
- 1.代碼塊3中我節(jié)選了幾個重要的方法,我們等會兒就會圍繞這幾個方法來講解方案,在這之前我們需要了解幾個前提:
- 1.ECV 交給 子VG 的事件為啥只支持點擊事件?原因很簡單,主要是因為Move、LongPress、Fling 等等手勢都是 ECV 必須消耗的手勢,甚至 ECV 還需要消耗第一次點擊 VG 的事件。所以為了不讓 ECV 和 子 VG 沖突,子 VG 只有點擊事件可以接收。
- 2.子 VG 只能接收選中了該子 VG 之后的點擊事件。原因也很簡單,我們在設計框架的時候大部分對 WsElement 的操作都建立在該 WsElement 被選中之后,點擊事件也是如此。
- 3.在 2 的基礎上有些人讀者肯定就會想到一個問題:如果我選中了一個 WsElement,ECV 對于移動手勢的處理必須要 down 手勢,而子 VG 的點擊事件也需要 down 手勢。這樣一來不還是沖突了嗎?這個問題我會在下一段講解代碼的時候解決。
- 2.那么閑話不多說,下面我們來解析代碼塊3:
- 1.首先是 onInterceptTouchEvent,這個方法用于讓 ECV 攔截所有經(jīng)過它的手勢,這樣一來 ECV 對于手勢處理的優(yōu)先級最高,只有 ECV 不需要的手勢才會被交給子 VG,正如我們前面說的選中 WsElement 之后的點擊事件。
- 2.然后是 dispatchTouchEvent,這個方法是 ECV 2的父 view 將事件交給 ECV 時調用的方法,也是 ECV 在自己內(nèi)部進行事件分發(fā)的起始方法。我們可以看見里面 clone 了 up 和 down 事件的 MotionEvent 并儲存了起來以便后面使用。直到注意的是:MotionEvent 的 copzy 方法雖然是一個 public 方法,但是不知道從哪個版本開始這個 copy 方法被 hide 了。所以這里我們只能使用反射的方式來對 MotionEvent 進行 clone。當然因為這里只是 clone 從 down 到 up 這一連串事件中的 down 和 up MotionEvent,對性能來說基本上沒有影響。
- 3.最后是 selectedClick 方法,我們前面提到了在選中 WsElement 之后,ECV 的移動手勢和子 VG 的點擊事件都需要用到 down 事件。所以我們的解決方案就是:down 事件還是給 ECV 去消耗,我們在 up 事件的時候手動調用兩次子 VG 的 dispatchTouchEvent 依次傳入前面儲存的 down 和 up 的 MotionEvent。這樣一來如果 VG 不旋轉的話事件分發(fā)是一切正常的,如果 VG 旋轉了,MotionEvent 中的 x、y 的坐標也需要旋轉相應的角度。當然我們前面提到事件分發(fā)目前只支持 view 使用 LayoutParam 的方式更新。
3.源碼流程簡析
這一節(jié)我主要會通過一個簡單的 demo 來講解一下整個源碼的流轉過程,讓讀者讀控件整體的運行方式有個簡單的了解。這一節(jié)主要是講解源碼,所以讀者一定要去 clone 源碼,跟隨文章的腳步前進。
(1).添加元素
- 1.簡單的初始化動作我就不贅述了,我們從 MainActivity 的 addTestElement 按鈕開始。點擊后先會創(chuàng)建一個 TestElement 這個是我測試用的元素,里面代碼很簡單也不說了。然后會依次調用 unSelectElement 和 addSelectAndUpdateElement 方法。unSelectElement 是取消當前選中的元素,這個留在后面分析,我們先看 addSelectAndUpdateElement。
- 2.addSelectAndUpdateElement 是一個比較組合方法,里面調用了 addElement、selectElement、update,也就是添加元素,選中元素,更新元素。我們一個個來分析::
- 1.addElement:這個方法里主要做了下面這些事情:
- 1.進行數(shù)據(jù)檢查,如果被添加的 WE 為空或者該 WE 已經(jīng)在 ECV 中,那么添加失敗。
- 2.在 ECV 中我維持了一個 WE 的 LinkedList,所有的 WE 都存于其中,每次 add 的時候 WE 都會被添加到 list 的頂部 ,其他 WE 的 mZIndex 也會順勢更新。
- 3.調用 WE.add 方法,里面初始化了 mElementShowingView 并且將其添加到了 ECV 中,這里的更具體初始化流程我會在后面一點會仔細講。
- 4.調用監(jiān)聽器的對應方法,且調用自動取消選中的方法(ECV 可以被外部決定是否自動取消選中)。
- 2.selectElement:WE 被 add 了之后,我們這里直接將其選中,代碼里面主要做了下面這些事情:
- 1.進行數(shù)據(jù)檢查,如果需要選中的 WE 沒有被添加到 ECV 中則選中失敗。
- 2.將需要選中的 WE 從 list 中移除然后添加到 list 的頂部,然后順便更新其他 WE 的 mZIndex。
- 3.調用 WE 的 select 方法,里面主要就是更新要選中的 WE 的數(shù)據(jù)。
- 4.調用監(jiān)聽器對應的方法。
- 3.update:前面都做好了,就需要將 WE 調整到其應該的狀態(tài),也就是進行我們在上一節(jié)中說的兩種 view更新模式中的一種,這里就不贅述了。
- 1.addElement:這個方法里主要做了下面這些事情:
- 3.WE.add:如果你仔細 WE 的源碼你會發(fā)現(xiàn),mElementShowingView 真正初始化且添加到 ECV 中的時機不是在 WE 創(chuàng)建的時候,而是如 2 中說在 ECV.addElement 的時候。這個方法里主要做了下面這些事情:
- 1.如果 mElementShowingView 沒有被初始化過就調用 initView 來創(chuàng)建一個 view,initView 是抽象方法子類必須實現(xiàn)它。我們以 TestElement 做例子,可以看見它的 initView 里面就是創(chuàng)建了一個 ImagaView。
- 2.從 initview 中獲取了一個 view 之后就會使用 LayoutParam 的方式將 view 添加到 ECV 中去,從這里我們可以知道的是:WE 中的 mElementShowingView 在初始化的時候 left 和 right 都是0,也就是處于 ECV 的左上角,長寬則是在創(chuàng)建 WE 時設置的 mOriginWidth 和 mOriginHeight。
- 3.如果 mElementShowingView 已經(jīng)被初始化過了,那么這里就會更新一下它。
(2).元素單指手勢
元素手勢不像添加元素那樣需要外部調用,元素手勢是通過事件分發(fā)觸發(fā)的,所以我們可以從 ECV.onTouchEvent 方法入手
1.看 ECV.onTouchEvent 的時候,我們先跳過前面的所有代碼,直接看方法的最后一行。這里使用了 GestureDetector,我想很多讀者都用過我就不贅述基礎用法了。我們直接找到它定義的地方 addDetector 方法。
-
2.對于元素單指手勢的處理,主要看三個觸摸事件:down、move、up。所以我們直接看 GestureDetector 的 onDown、onScroll、onSingleTapUp 三個回調。
- 1.onDown 它里面跳過了雙指手勢,直接進入了 singleFingerDown 方法中,里面的邏輯如下:
- 1.通過 findElementByPosition 根據(jù) down 的位置找到當前位置下最頂層的 WE。
- 2.如果當前有選中的 WE 且與當前觸摸 WE 是同一個的話,那么先調用 downSelectTapOtherAction,這個函數(shù)可以被子類覆寫,默認返回 false。也就是說子類可以優(yōu)先處理當前事件,如果子類處理了這個事件,那么 return。如果子類不處理,那么將 mMode 標記為 SELECTED_CLICK_OR_MOVE,表示最終的手勢可能是點擊元素,也可能是移動元素。具體的行為需要 move 或者 up 的時候才能判定。
- 3.如果當前有選中的 WE 但與當前觸摸的 WE 不是同一個的時候也分兩種情況:一種情況是觸摸的 WE 不存在,此時表示將 mMode 標記為 SINGLE_TAP_BLANK_SCREEN 表示點擊了 ECV 的空白區(qū)域。另一種情況是觸摸的 WE 存在,此時表示重新選中了一個 WE。
- 4.如果當前沒有選中的 WE,也會有兩種情況:一個是觸摸的 WE 也不存在,那么和前面一樣表示點擊空白區(qū)域。否則的話就是選中一個 WE。
- 2.onScroll 中會優(yōu)先將 move 事件交給 scrollSelectTapOtherAction,該方法也可以被子類覆寫,同樣默認返回 false,如果子類處理了這個事件,那么就直接 return 了。否則當 mMode 為 SELECTED_CLICK_OR_MOVE(已經(jīng)選中了 WE 開始移動)、SELECT(沒有選中 WE 開始移動)、MOVE(WE 移動過程中) 三種情況中的一種的時候,都可以觸發(fā)移動手勢。具體的邏輯在 singleFingerMove 中:
- 1.先根據(jù) mMode 的狀態(tài),調用 singleFingerMoveStart 或 singleFingerMoveProcess。singleFingerMoveStart 中調用了監(jiān)聽器和 WE 的對應方法,里面基本沒什么邏輯。 singleFingerMoveProcess 中也調用了監(jiān)聽和 WE 的對應方法,但是 WE 的對應方法中更新了 mMoveX 和 mMoveY 的數(shù)據(jù)。
- 2.調用 update 更新 WE 中的 view。將 mMode 設置為 MOVE,表示處于移動中。
- 3.onSingleTapUp 中首先也是過濾掉了雙指手勢,然后調用了 singleFingerUp 方法:
- 1.mMode 為 SELECTED_CLICK_OR_MOVE,到這里的時候才能確認,用戶的行為是選中了元素之后的點擊,我們在前面分析過了這里面的事件分發(fā)的機制,這里也不贅述了。
- 2.mMode 為 SINGLE_TAP_BLANK_SCREEN,表示點擊 ECV 的空白處,這里調用的 onClickBlank 也是可以被子類覆寫的,可以實現(xiàn)一些自己的邏輯。
(3).元素雙指手勢以及刪除
剩下的就交給讀者去閱讀源碼吧,實在是寫不動了,留點精力在最后一章仿寫抖音貼紙控件,那么下一章見。
- 1.onDown 它里面跳過了雙指手勢,直接進入了 singleFingerDown 方法中,里面的邏輯如下:
三、仿寫一個抖音貼紙控件
最后一章我會基于我們的控件來模仿抖音的靜態(tài)貼紙,當然不會所有細節(jié)都還原,但可以肯定的是有些地方我們的仿制品會做的比抖音好。
一個好消息是,我把 github 中的核心代碼打包上傳到了 JCenter 中,如果讀者想要用這個包只要像使用普通依賴一樣在 build.gradle 文件中添加:implementation 'com.whensunset:sticker:0.2'。這個庫會一直維護,大家可以多提 issue。先上幾個功能圖吧:




1.特性
這一節(jié)來講講我們的庫中含有的特性吧。
- 1.單指移動、雙指旋轉縮放、雙指移動:這些功能是 ECV 和 WE 直接就有的功能,抖音也有。
- 2.選中時的裝飾邊框、單指旋轉縮放、點擊刪除:這些功能是在 DECV 和 DecorationElement 這一層加上的,抖音也有。
- 3.位置輔助線:這個功能 ins 做的非常好,抖音的這個功能非常爛。所以我是模仿 ins 的,RLECV 支持這個功能。
- 4.垃圾桶:這個功能 ins 和 抖音都有,ins 的用戶體驗更好,但是能力有限模仿不來 ins,所以模仿了抖音,TECV 支持這個功能。
- 5.動畫效果:這個功能 ins 和抖音半斤八兩。AnimationElement 是動畫的具體實現(xiàn)類。我在實現(xiàn)的時候 DECV 中添加了一個 onFling 后的滑動效果,還是挺好玩的,所以我們仿寫的體驗應該是更好的。
2.仿寫
其實大部分核心代碼都集成到庫中去了,所以我們只需要寫一點點代碼就能仿寫抖音貼紙的大部分功能,有些地方我們甚至做得比抖音更好。
我們的測試代碼在 github 上項目中的 test moudle 中,大家可以結合代碼來看接下來的分析:
- 1.正如我們在前面說的那樣,我們的庫中含有好幾個不同功能的 ECV,從架構圖和上一節(jié)的分析中我們可以知道 TECV 是繼承結構中最底層的類,里面包含了我們上一節(jié)中列舉的所有功能。所以我們在 activity_main 中就可以用 TECV 來作為元素的容器 view。
- 2.布局定義好了,我們看 MainActivity 中,這里有一句非常重要的代碼 Sticker.initialize(this); 它是在使用本框架之前必須調用的方法,里面會初始化一些東西。這個建議在初始化 App 的時候調用。
- 3.添加一個 TestElement 我們在上一章中已經(jīng)講過了,這里就不贅述了。我們看 addStaticElement 這里點擊會觸發(fā)添加一個 StaticStickerElement,這個就是靜態(tài)貼紙元素。
- 4.進入 StaticStickerElement 中查看代碼你會發(fā)現(xiàn)非常簡單,因為 StaticStickerElement 用的 view 是 SimpleDraweeView,所以里面的主要代碼是構造一個 ImageRequest。剩下其他的東西我已經(jīng)都實現(xiàn)好了。雖然代碼簡單,但是 StaticStickerElement 不僅可以展示本地圖片,網(wǎng)絡圖片同樣可以展示。怎么樣是不是感覺這個庫用起來非常簡單,但是效果卻非常好呢?
- 5.寫到這里本篇博客也差不多過萬字了,所以庫里面更多的功能就等著讀者去挖掘了。過一陣子我有時間會在 github 上貼一個本庫的使用文檔,求star、fork、issue。
四、結尾
又是一篇萬字文章,希望大家能夠喜歡。最近比較忙,博客更新不會像以前那么穩(wěn)定了,望大家多多包涵、但即使再忙我的文章也都會是精心挑選的技術干貨,不會只是為了增加曝光率而亂發(fā)水文和制造焦慮的文章。長路漫漫,咱們一起前進。
連載文章
- 1.從零開始仿寫一個抖音app——開始
- 4.從零開始仿寫一個抖音App——日志和埋點以及后端初步架構
- 5.從零開始仿寫一個抖音App——app架構更新與網(wǎng)絡層定制
- 6.從零開始仿寫一個抖音App——音視頻開篇
- 7.從零開始仿寫一個抖音App——基于FFmpeg的極簡視頻播放器
- 8.從零開始仿寫一個抖音App——跨平臺視頻編輯SDK項目搭建
- 9.從零開始仿寫一個抖音App——Android繪制機制以及Surface家族源碼全解析