抖音、ins、微信功能大比拼——Story的貼紙文字

本文首發於微信公衆號——世界上有意思的事,搬運轉載請註明出處,不然將追究版權責任。java

GitHub地址

庫依賴: implementation 'com.whensunset:sticker:0.2'

近兩個月沒有更新博客了,感受已通過氣了,哈哈。其實我在準備一個大招,而這個大招準備時間比較長,你們好好期待吧。本篇文章算是大招的前菜,來填補一下這麼久沒有更新的間隙。固然本篇文章也不是水水而過的,裏面的乾貨很是多,由於我最近幾個月的工做內容就和這個相關——story 的文字、貼紙控件。android

閱讀須知:git

  • 1.文字、普通貼紙、動態貼紙等等統稱爲——元素
  • 2.後面會有一些英文縮寫:TextureView——TV、RenderThread——RT、ViewGroup——VG、Instagram——ins、ElementContainerView——ECV、DecorationElementContainerView——DECV、ElementActionListener——EAL、WsElement——WE、RLECV——RuleLineElementContainerView、TECV——TrashElementContainerView
  • 3.抖音、多閃——抖閃

本文分爲如下章節,讀者可按需閱讀:程序員

  • 1.story產品技術分析——聊一聊市面上能夠發佈 story 的 app 的功能以及可能的技術實現。
  • 2.Android端貼紙文字架構與實現——講一講如何實現一個集各家之長的 android 端文字貼紙功能。
  • 3.仿寫一個抖音貼紙控件——基於2中的核心代碼,簡單實現抖音 app 中的貼紙控件。

1、Story產品技術分析

首先市面上有不少 app 都支持 story 以及相似概念的視頻的拍攝和發佈。國外的 story 鼻祖是 Ins。國內的微信的時刻視頻、多閃的視頻拍攝、抖音的隨拍等等,都是借鑑了 Ins 的 story。本章的分析也是創建在對上面的四款 app 的分析之上。github

1.產品功能分析

下表是我仔細把玩了中外比較有名的能夠發佈 story 視頻的 app 以後的出的結論,下面咱們來根據各個產品的功能仔細分析一下。web

Instagram 抖音 多閃 微信
文字 有、功能最豐富 有、功能比較豐富 有、功能比較少 有、功能最少
文字放大 有 emoji 時模糊、無則清晰、放大不卡頓 不模糊、放大卡頓 不模糊、放大卡頓 有點模糊、放大不卡頓
動態貼紙 有、只支持gif、跟手 有、支持視頻格式、不跟手 有、支持視頻格式、不跟手 有、只支持 gif、跟手
功能貼紙 有、功能豐富 有、功能通常 有、功能通常 有、只有地理位置貼紙
普通貼紙 有、很是跟手 有、不跟手 有、不跟手 有、很是跟手
文字、貼紙是否可相互覆蓋 能夠 不能夠 不能夠 能夠
  • 1.首先 Ins 算是無冕之王了,畢竟 story 這個概念是就是 ins 帶火的。能夠說 ins 的功能最全最精細,若是咱們要找個標杆的話那麼非 ins 莫屬。
  • 2.從上面的圖咱們發現,抖音、閃多的功能很是相似,畢竟是父子關係,因此這兩家咱們能夠當成一家分析(後稱抖閃)。在我體驗的過程當中抖閃有一個體驗點(表中沒有列出)是超過了 ins 的。那就是文字編輯狀態切換的流暢程度,抖閃使用了流暢的過渡動畫,ins 則是生硬的出現和消失。這裏在我看來就有點東西了,至因而啥東西我會在後面技術分析的時候點出。
  • 3.這樣看來微信彷佛有種迷之自信,不管是功能仍是體驗其實都比不上其餘三位玩家,可是惟一值得稱讚的點就是微信的貼紙可以使用咱們平時聊天沉澱下來的表情包。這個算不算是一種降維打擊就交給讀者去評判了。
  • 4.再來看看貼紙的跟手問題與文字貼紙是否能夠相互覆蓋的問題。
    • 1.咱們發現若是貼紙只支持 gif,就會跟手。若是貼紙支持視頻格式,就會不跟手。
    • 2.一樣若是貼紙支持 gif,文字和貼紙就能夠相互覆蓋,反之則不能相互覆蓋。
    • 3.上面提到的兩個問題我也會在後面的技術分析的時候點出答案。
  • 5.最後一個問題就是文字放大模糊與卡頓的問題。微信文字放大以後都會出現有點模糊現象,而抖閃則不會(這裏指的是編輯的視頻而不是發佈後的視頻)。微信使用了一個很是雞賊的方式使得文字最終並不會很模糊,那就是限制文字的放大倍數,並且文字不容許調節字體大小。而抖閃有個問題就是文字含有多個 emoji 的時候放大會很是卡頓且會閃爍,微信則沒有這個問題不管多少 emoji 縮放都很是流暢。ins 則是個特例,他有 emoji 的時候放大會模糊,無 emoji 的時候放大不模糊,並且放大始終不卡頓。這個問題我也會在技術分析的時候詳細解釋。

2.技術分析

一個功能的誕生過程就是產品和技術相互妥協(撕逼)的過程。因此這一節我就來聊聊上一節中分析的四個 app 體驗上達不到盡善盡美的技術緣由,也爲咱們後面的技術實現排坑。編程

(1).TextureView(SurfaceView)與ViewGroup之爭

關注個人同窗應該知道我上一篇博客發表的是 SurfaceView家族源碼全解析。當我知道要作這個需求的時候其實我第一個想到的是用 TV。由於不管文字也好、貼紙也罷都能被繪製到 Surface 上面,並且性能彷佛也不會不好。可是最終的結果是我多加了幾天班徹底重構了使用 TV 做爲基礎繪製容器的代碼。千言萬語匯成一首詩:代碼千萬行,思考第一行。架構拎不清,加班到天明。那麼下面我就來說講 TV 和 VG 做爲基礎繪製容器的優劣勢:canvas

  • 1.TV 的優點:
    • 1.繪製邏輯清晰,能夠手動控制繪製流程。
    • 2.彷佛沒了。。。
  • 2.VG 的優點:
    • 1.有大量的現成控件能夠進行組合,這些組合基本上能夠知足咱們的全部需求。能夠方便功能貼紙的開發。
    • 2.有整套的事件分發流程可使用,方便 元素 響應事件。
    • 3.在已經有一個 TV 的狀況下(例如編輯的時候視頻使用 TV 播放),VG 的刷新對 RT 的影響很小。而 TV 則會增大 RT 的負載。這裏的直觀體驗就是:縮放移動元素的時候,視頻播放會很是卡頓,緣由就是咱們的 TV 刷新搶佔了視頻播放的 TV 的 cpu 時間(這也是我最終放棄 TV 的緣由)
    • 4.使用 VG 咱們就可使用各類各樣的動畫來優化用戶體驗,讓 元素 的狀態切換很是順滑。例子就是 抖閃 文字編輯狀態切換的動畫。
    • 5.不用咱們本身用 canvas 寫各類各樣的繪製邏輯了。**那些代碼寫的時候我和上帝都能看懂,可是幾個月以後就只有上帝能看懂了。**用知乎的話來講,這種代碼就是——屎山
  • 3.其實咱們比較了這麼多發現,VG 的大部分好處都是 android 的 framework 層給的。若是咱們用 TV 來實現的話,只是從新造一個漏洞百出的輪子。**從工期、用戶體驗、代碼擴展性等等各個方面的比較來看 VG 都是完爆 TV 的。**請原諒兩個月前的我作出了選擇 TV 這個愚蠢的選擇。**親愛的讀者若是你以爲我幫你蹚過了這個大坑,那麼就快點關注個人微信公衆號:世界上有意思的事。**乾貨多多等你來看。

(2).如何顯示動態貼紙

由前面的對比咱們知道,是否支持視頻格式的資源與是否跟手有着不可調和的矛盾。ins 和 微信選擇了跟手,抖閃則選擇了支持視頻格式資源。接下來咱們就來分析這裏面的技術原理與取捨緣由後端

  • 1.首先咱們得知道爲了支持多個具備視頻資源的動態貼紙的顯示而在 framework 層展現多個視頻播放窗口是很是愚蠢的行爲。由於通常來講咱們的背景就是視頻播放器,咱們徹底能夠經過 native 層的能力將多個動態貼紙的視頻資源整合到視頻播放器中。也就是說**始終只有一個視頻播放器,動態貼紙的資源交給播放器去播放。**固然這樣的視頻播放器即便有開源的也須要根據本身的功能進行相應的裁剪。四個 app 中抖閃是選擇了這種方案,咱們能夠簡單的判斷這種播放器所具備的能力:
    • 1.可以播放普通的視頻(這個是廢話)
    • 2.可以對視頻進行位移、縮放、旋轉這類的操做
    • 3.播放器能在播放視頻的狀況下添加多個子視頻,且子視頻也支持位移、旋轉、縮放等等功能。
    • 4.子視頻的各類信息能夠在主視頻播放的過程當中進行實時變化,重要的是性能不能夠太差,像抖閃目前這種情況算是在用戶不可接受的邊緣試探吧。
  • 2.ins 和 微信都選擇了跟手,那麼顯而易見他們的實現方式就是在 framework 層將 gif/webp 的資源顯示在 view 上,那麼跟手也就是理所固然的事了。至於啥控件能顯示 gif 和 webp 的圖呢?那固然是 Fresco 啦,恰好也是 FaceBook 出品。
  • 3.如今咱們知道其實支持視頻格式的資源比 gif 要難上不少,只支持 gif 的話我可以獨立作出這個功能來。一旦支持視頻格式那麼光我一我的目前來講是搞不定的(固然後面咱們的視頻編輯 sdk 開發完成以後我應該就能搞定了)。那麼支持視頻格式的資源有什麼好處呢?下面我來列舉一下
    • 1.可以精細的控制動態貼紙的顯示範圍,由於 framework 層的 gif 咱們是控制不了的。而若是是視頻資源的話 native 層能夠控制視頻的進度,播放區域等等屬性。
    • 2.視頻格式比 gif 更具拓展性,展現畫面的精細程度也更高。
  • 4.其實抖閃的實現方式還會有一個缺點就是:文字、貼紙不能相互覆蓋了,由於貼紙始終是被渲染在視頻中的,文字則是用 view 的方式來顯示。貼紙在 z 軸上永遠都會在文字的下方。

(3).文字的顯示方式之爭

若是讀者看透了(1)和(2)的話,那麼我相信你的內心已經很是清楚四種 app 都是採起什麼樣的方式來顯示文字的。我這裏也就簡單分析一下:api

  • 1.毋庸置疑四種 app 都是使用了 VG 來當作基礎繪製容器。ins 和 微信由於支持 gif,不用說確定是用 view 來展現 gif 的。而抖閃雖然貼紙都是交給播放器渲染的,可是他們有各類功能貼紙,這些貼紙的組合也只能是使用 view 來組合。要否則代碼真的無法維護了,對於這種代碼我親身體會過。
  • 2.那麼如今問題就來了一樣是使用 view 來展現文字,爲啥抖閃、ins、微信的最終表現卻各不相同呢?這裏的一個關鍵點就是:view 的種類。
    • 1.咱們首先能夠確認的是微信在文字編輯完成以後,會獲取 EditText 的 view 截圖,最終在界面上縮放位移旋轉的是一個類 ImageView,這就解釋了文字放大模糊的現象。而微信**「巧妙」**的限制了文字縮放的倍數,這樣就讓用戶最終不會以爲文字很糊。
    • 2.抖閃是一家,因此他們展現文字的方式一模一樣,使用 EditText 來展現編輯完成的文字。也就是說在界面上縮放旋轉的 view 仍是 EditText。這樣的好處顯而易見,用戶就算把文字放的很大顯示出來仍是很是清晰。**可是我前面也說了這樣的方案有一個缺陷就是:EditText 在有比較多的 emoji 且放大倍數比較大時,操做會很是卡頓,時而還有閃屏的現象。**這個應該是 EditText 自己的 bug,感受 google 自家若是不解這個 bug 的話,就要一直留着了。
    • 3.ins 結合了這兩種方案。在有 emoji 的狀況下蛻化成了使用 ImageView 來顯示文字的截圖,沒有 emoji 的狀況下則使用 EditText 來顯示文字。這也是將 ins 稱爲無冕之王的一個緣由,它照顧到了各類用戶體驗細節,盡力給用戶最好的體驗。不過最終四家誰的方案最好就交給讀者和用戶去評判了。
    • 4.我前面說了抖閃在文字編輯狀態的切換上比 ins 作得好,由於他們用上了動畫來切換。微信由於使用的 ImageView 來展現文字截圖很差作這個動畫能夠理解。可是 ins 應該有作這個動畫的方法的,我的感受多是爲了讓用戶在有無 emoji 時體驗一致而沒有作這個動畫吧。

(4).View的縮放位移之爭

咱們都知道 android 中讓 view 變化大小和位置有兩種方式,一個是改變 view LayoutParam 中的真實屬性,一個是設置 view 的 scale 和 translation。下面咱們就來說講這兩種方式的特色,固然最終咱們的實現方案中兩種都會有

  • 1.改變 LayoutParam 來改變 view 的特色:
    • 1.view 中的內容始終是最初定義的大小,例如 view 中有文字那麼文字的字體大小不會改變。
    • 2.view 若是是一個 VG 的話那麼它會從新進行佈局。
    • 3.可以比較方便的進行事件分發,好比我如今的實現中在這種模式下就可以進行準確的事件分發。
  • 2.使用 scale 和 translation 來改變 view 的特色:
    • 1.view 中的內容可以直接放大和縮小,這個特性適合咱們的絕大多數需求場景。
    • 2.view 不會從新進行 measure、layout 和 draw。性能上彷佛比前一種方式好一點。
    • 3.也可以進行事件分發,可是應該有點坑,目前我在這種模式下實現不了準確的事件分發,多是個人實現有問題。

2、Android端貼紙文字控件架構與實現

1.架構方式

咱們第一節先講講文字貼紙控件的架構實現,我會基於下面的 圖1 和 github 上的代碼進行講解。建議你們把代碼 clone 下來,固然別忘了給個 star。

文字貼紙架構.jpg

咱們先來根據圖1來說講整個控件的架構

  • 1.咱們先從總體來看:
    • 1.咱們在前一章分析了整個控件的繪製容器應該是一個 VG。因此圖中的 ElementContainerView 就是這樣一個容器,簡單歸納一下它有這些功能:
      • 1.處理各類手勢事件,這裏的手勢包括單指和雙指。
      • 2.添加和刪除一些 view。這裏的 view 用於繪製各類元素。
      • 3.提供一些 api 讓外部可以操控 view。
      • 4.提供一個 listener,讓外部可以監聽內部的流程。
    • 2.有了繪製容器,咱們須要向繪製容器裏面添加 view。而 view 在用戶操做的過程當中須要有各類數據,因此這裏我用了 WE 來封裝 須要展現的view,其內部有下面這些東西:
      • 1.各類用戶操做過程當中須要的數據例如:scale、rotate、x、y等等。
      • 2.有一些方法可以經過數據來更新 view。
      • 3.提供一些 api 讓 ECV 能操縱 WE 裏面的 view。
    • 3.由 ECV 和 WE 就能繼續繼承出各類各樣的擴展控件。
  • 2.總體講完了,咱們就能夠來仔細的講講圖中的流程
    • 1.先講橫着的箭頭:外部/內部調用,外部須要調用 ECV 來進行對 WE 的增刪改查等操做時會進入這個路徑,這個路徑裏能夠有下面這些操做:
      • 1.addElement:向 ECV 中添加一個元素。
      • 2.deleteElement:從 ECV 中刪除一個元素。
      • 3.update:讓 WE 中的 view 根據當前數據刷新狀態。
      • 4.findElementByPosition:找到傳入的座標下的最頂層的 WE。
      • 5.selectElement:選中一個 WE 且將其調到最頂層。
      • 6.unSelectElement:取消選中一個 WE。
    • 2.再來說豎着的箭頭:手勢事件流,這裏中間會經歷一些內部邏輯咱們後面來說,最終事件流會觸發下面的一系列行爲:
      • 1.單指移動的整個流程:當咱們選中了一個 WE 的時候就能夠對它進行移動。這裏移動能夠分爲開始、進行中、結束。每一個事件都會調用 WE 的對應方法以更新其內部的數據而後更新 view。
      • 2.雙指旋轉縮放的整個流程:當咱們選中了一個 WE 的時候能夠用雙指對它進行縮放和旋轉。這裏能夠分爲開始、進行中、結束。這裏也會調用 WE 的對應方法更新數據而後更新 view。
      • 3.選中元素再次點擊:當咱們選中了一個 WE 的時候,能夠對其再次點擊。由於 WE 表示的是一個 view,因此咱們能夠直接將事件交給 view 觸發其內部的各類響應。固然咱們也能夠添加一個 VG 來做爲一個 WE 的繪製 view。此時咱們能夠把點擊事件交給 VG,它還能夠繼續將事件分發給子 view。注意:由於 ECV 須要接收移動事件,因此目前只有點擊事件可以被分發。
      • 4.點擊空白區域:當咱們沒有點擊任意 WE 的時候能夠進行一些操做,例如清除當前 WE 的選中狀態。這個行爲是能夠繼承的,能夠交由子類來覆寫。
      • 5.onFling:這是一個「拋」的手勢,能夠用來實現一些好玩的行爲,例如手指擡起的時候讓 WE 再滑動一段距離。這個行爲也是可繼承的,能夠交由子類覆寫。
      • 6.子類事件:咱們看上面其實感受觸發的事件比較少。因此在 down、move、up 的時候會優先調用三個方法 downSelectTapOtherAction、scrollSelectTapOtherAction、upSelectTapOtherAction。這三個方法能夠被子類覆寫,若是返回 true 的話表示事件已經消耗了,ECV 就不會再觸發其餘事件。這樣一來子類也能夠對手勢進行擴展,例如按住某個地方單指縮放等等。
      • 7.我圖中 ECV 也實現了一個子類 DECV,這個類簡單的加兩個手勢:
        • 1.單指移動縮放:相似抖音的隨拍,按住元素的右下角的時候能夠用拖動來對元素進行縮放和旋轉。
        • 2.刪除:相似抖音的隨拍,點擊元素左上角的時候能夠直接刪除元素。
    • 3.圖1中有一個特性其實沒有畫出來由於畫不下了,那就是:ECV 在1和2中的幾乎全部行爲都能被外部監聽,ElementActionListener 就是負責監聽的接口。ECV 中存有一個 EAL 的 set 集合因此監聽器能夠添加多個。

2.技術點實現

我在開發整個控件的時候遇到過比較多的技術實現上的難點,因此這一節就選一些來說講,讓讀者在看源碼的時候不會特別困惑。

(1).定義數據結構與繪製座標系

-----代碼塊1----- com.whensunset.sticker.WsElement

public int mZIndex = -1; // 圖像的層級
  
  protected float mMoveX; // 初始化後相對 mElementContainerView 中心 的移動距離
  
  protected float mMoveY; // 初始化後相對 mElementContainerView 中心 的移動距離
  
  protected float mOriginWidth; // 初始化時內容的寬度
  
  protected float mOriginHeight; // 初始化時內容的高度
  
  protected Rect mEditRect; // 可繪製的區域
  
  protected float mRotate; // 圖像順時針旋轉的角度
  
  protected float mScale = 1.0f; // 圖像縮放的大小
  
  protected float mAlpha = 1.0f; // 圖像的透明度
  
  protected boolean mIsSelected; // 是否處於選中狀態
  
  @ElementType
  protected int mElementType; // 用於區別元素種類
  
  // Element 中 mElementShowingView 的父 View,用於包容全部的 Element 須要顯示的 view
  protected ElementContainerView mElementContainerView;
  
  protected View mElementShowingView; // 用於展現內容的 view
  
  protected int mRedundantAreaLeftRight = 0; // 內容區域左右向外延伸的一段距離,用於擴展元素的可點擊區域
  
  protected int mRedundantAreaTopBottom = 0; // 內容區域上下向外延伸的一段距離,用於擴展元素的可點擊區域
  
  // 是否讓 showing view 響應選中該 元素 以後的點擊事件
  protected boolean mIsResponseSelectedClick = false;
  
  // 是否在刷新 showing view 的時候,真正修改 height、width 之類的參數。通常來講只是使用 scale 和 rotate 來刷新 view
  protected boolean mIsRealUpdateShowingViewParams = false;
複製代碼

函數未動數據先行,數據結構是一個框架很是核心的東西,定義了一個好的數據結構能夠省去不少沒必要要的代碼。因此這一小節咱們來根據代碼塊1定義一下數據結構和 view 繪製座標系

  • 1.咱們將 WE 所在的 ECV 做爲 WE 中 view 的可繪製區域,代碼塊1中的 mEditRect 就是這個區域表明的矩形。因此 mEditRect 通常爲**[0, 0, ECV.getWidth, ECV.getHeight],mEditRect 的單位爲px**。

  • 2.咱們定義的座標系原點在 mEditRect 的中心點,也就是 ECV 的中心點。mMoveX、mMoveY 分別表示 view 距離座標系原點的距離。由於它們倆默認爲 0,因此通常 view 被添加到 ECV 中的時候默認位置就在 ECV 的中心。這兩個參數的單位爲px

  • 3.咱們的座標系具備 z 軸,mZIndex 就是 z 軸的座標,z 軸表示 view 的層疊關係,mZIndex 爲 0 時表示 view 在 ECV 的頂層。mZindex 默認爲 -1,表示 view 沒有被添加到 ECV 中。mZIndex 是整數

  • 4.咱們定義 mRotate 爲正時 view 順時針轉動,mRotate 的區間爲[-360,360]。

    5.咱們定義 view 沒有縮放的時候 mScale 爲 1,mScale 爲 2 的時候表示 view 放大 2 倍,以此類推。

  • 6.mOriginWidth 和 mOriginHeight 爲 view 的初始大小,單位是px

  • 7.mAlpha 爲 view 的透明度,默認爲 1 且小於等於1。

  • 8.剩下的參數就不用解釋了,代碼裏面都有註釋。

(2).WE中的View是如何更新的

從前面的分析咱們知道了在 ECV 處理手勢的過程當中會不斷更新 WE 中的各類數據,更新完了數據以後會調用 WE.update 來刷新 view的狀態。咱們就來經過代碼塊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 的真實參數來更新 view:代碼塊2中咱們看見有一個 flag 來區分兩種 view 的更新方式。本方式也很是簡單,由於咱們的 ECV 是繼承於 AbsoluteLayout 的因此先獲取 mElementShowingView 的 LayoutParam 而後再將相應的數據設置進去就好了。這裏有兩個要注意的地方:
    • 1.這種方式每次都會從新 measure、layout、draw
    • 2.這種方式目前我已經成功實現了讓 view 在爲 VG 的時候進行事件分發。
  • 2.設置 view 的畫布參數來更新 view:第二種方式是經過設置 view 在底層的 RenderNode 的參數來更新 view。咱們其實能夠簡單的類比爲對 canvas 作 scale、rotate、translate。這種方式有兩個須要注意的地方:
    • 1.這種方式不會更新 measure、layout、draw 等方法,性能應該比1號。
    • 2.這種方式目前只能在爲 view 的時候響應事件,若是 view 爲 VG 那麼事件將會錯亂,暫時尚未好的解決方案。
  • 3.上面兩種 view 更新方式有着一些共同點:
    • 1.咱們都對 view 的 mMoveX、mMoveY 進行了一個限制,若是當前的數據超過了限制就將這兩個參數設置爲上下限值。
    • 2.都使用 setRotation 來讓 view 實現旋轉
    • 3.更新結束的時候須要 bringToFront 將 view 提到 ECV 的頂層。

(3).事件是如何從ECV交給子VG進行分發的

首先 android 的事件分發體系我就不贅述了,網上已經有不少資料了。我下面會結合代碼塊3講講具體的實現方案

-----代碼塊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中我節選了幾個重要的方法,咱們等會兒就會圍繞這幾個方法來說解方案,在這以前咱們須要瞭解幾個前提:
    • 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 攔截全部通過它的手勢,這樣一來 ECV 對於手勢處理的優先級最高,只有 ECV 不須要的手勢纔會被交給子 VG,正如咱們前面說的選中 WsElement 以後的點擊事件。
    • 2.而後是 dispatchTouchEvent,這個方法是 ECV 2的父 view 將事件交給 ECV 時調用的方法,也是 ECV 在本身內部進行事件分發的起始方法。咱們能夠看見裏面 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 不旋轉的話事件分發是一切正常的,若是 VG 旋轉了,MotionEvent 中的 x、y 的座標也須要旋轉相應的角度。固然咱們前面提到事件分發目前只支持 view 使用 LayoutParam 的方式更新。

3.源碼流程簡析

這一節我主要會經過一個簡單的 demo 來說解一下整個源碼的流轉過程,讓讀者讀控件總體的運行方式有個簡單的瞭解。這一節主要是講解源碼,因此讀者必定要去 clone 源碼,跟隨文章的腳步前進。

(1).添加元素

  • 1.簡單的初始化動做我就不贅述了,咱們從 MainActivity 的 addTestElement 按鈕開始。點擊後先會建立一個 TestElement 這個是我測試用的元素,裏面代碼很簡單也不說了。而後會依次調用 unSelectElementaddSelectAndUpdateElement 方法。unSelectElement 是取消當前選中的元素,這個留在後面分析,咱們先看 addSelectAndUpdateElement
  • 2.addSelectAndUpdateElement 是一個比較組合方法,裏面調用了 addElementselectElementupdate,也就是添加元素,選中元素,更新元素。咱們一個個來分析::
    • 1.addElement:這個方法裏主要作了下面這些事情:
      • 1.進行數據檢查,若是被添加的 WE 爲空或者該 WE 已經在 ECV 中,那麼添加失敗。
      • 2.在 ECV 中我維持了一個 WE 的 LinkedList,全部的 WE 都存於其中,每次 add 的時候 WE 都會被添加到 list 的頂部 ,其餘 WE 的 mZIndex 也會順勢更新。
      • 3.調用 WE.add 方法,裏面初始化了 mElementShowingView 而且將其添加到了 ECV 中,這裏的更具體初始化流程我會在後面一點會仔細講。
      • 4.調用監聽器的對應方法,且調用自動取消選中的方法(ECV 能夠被外部決定是否自動取消選中)。
    • 2.selectElement:WE 被 add 了以後,咱們這裏直接將其選中,代碼裏面主要作了下面這些事情:
      • 1.進行數據檢查,若是須要選中的 WE 沒有被添加到 ECV 中則選中失敗。
      • 2.將須要選中的 WE 從 list 中移除而後添加到 list 的頂部,而後順便更新其餘 WE 的 mZIndex。
      • 3.調用 WE 的 select 方法,裏面主要就是更新要選中的 WE 的數據。
      • 4.調用監聽器對應的方法。
    • 3.update:前面都作好了,就須要將 WE 調整到其應該的狀態,也就是進行咱們在上一節中說的兩種 view更新模式中的一種,這裏就不贅述了。
  • 3.WE.add:若是你仔細 WE 的源碼你會發現,mElementShowingView 真正初始化且添加到 ECV 中的時機不是在 WE 建立的時候,而是如 2 中說在 ECV.addElement 的時候。這個方法裏主要作了下面這些事情:
    • 1.若是 mElementShowingView 沒有被初始化過就調用 initView 來建立一個 view,initView 是抽象方法子類必須實現它。咱們以 TestElement 作例子,能夠看見它的 initView 裏面就是建立了一個 ImagaView。
    • 2.從 initview 中獲取了一個 view 以後就會使用 LayoutParam 的方式將 view 添加到 ECV 中去,從這裏咱們能夠知道的是:WE 中的 mElementShowingView 在初始化的時候 left 和 right 都是0,也就是處於 ECV 的左上角,長寬則是在建立 WE 時設置的 mOriginWidth 和 mOriginHeight
    • 3.若是 mElementShowingView 已經被初始化過了,那麼這裏就會更新一下它。

(2).元素單指手勢

元素手勢不像添加元素那樣須要外部調用,元素手勢是經過事件分發觸發的,因此咱們能夠從 ECV.onTouchEvent 方法入手

  • 1.看 ECV.onTouchEvent 的時候,咱們先跳過前面的全部代碼,直接看方法的最後一行。這裏使用了 GestureDetector,我想不少讀者都用過我就不贅述基礎用法了。咱們直接找到它定義的地方 addDetector 方法。

  • 2.對於元素單指手勢的處理,主要看三個觸摸事件:down、move、up。因此咱們直接看 GestureDetector 的 onDown、onScroll、onSingleTapUp 三個回調。

    • 1.onDown 它裏面跳過了雙指手勢,直接進入了 singleFingerDown 方法中,裏面的邏輯以下:
      • 1.經過 findElementByPosition 根據 down 的位置找到當前位置下最頂層的 WE。
      • 2.若是當前有選中的 WE 且與當前觸摸 WE 是同一個的話,那麼先調用 downSelectTapOtherAction,這個函數能夠被子類覆寫,默認返回 false。也就是說子類能夠優先處理當前事件,若是子類處理了這個事件,那麼 return。若是子類不處理,那麼將 mMode 標記爲 SELECTED_CLICK_OR_MOVE,表示最終的手勢多是點擊元素,也多是移動元素。具體的行爲須要 move 或者 up 的時候才能斷定。
      • 3.若是當前有選中的 WE 但與當前觸摸的 WE 不是同一個的時候也分兩種狀況:一種狀況是觸摸的 WE 不存在,此時表示將 mMode 標記爲 SINGLE_TAP_BLANK_SCREEN 表示點擊了 ECV 的空白區域。另外一種狀況是觸摸的 WE 存在,此時表示從新選中了一個 WE。
      • 4.若是當前沒有選中的 WE,也會有兩種狀況:一個是觸摸的 WE 也不存在,那麼和前面同樣表示點擊空白區域。不然的話就是選中一個 WE。
    • 2.onScroll 中會優先將 move 事件交給 scrollSelectTapOtherAction,該方法也能夠被子類覆寫,一樣默認返回 false,若是子類處理了這個事件,那麼就直接 return 了。不然當 mModeSELECTED_CLICK_OR_MOVE(已經選中了 WE 開始移動)、SELECT(沒有選中 WE 開始移動)、MOVE(WE 移動過程當中) 三種狀況中的一種的時候,均可以觸發移動手勢。具體的邏輯在 singleFingerMove 中:
      • 1.先根據 mMode 的狀態,調用 singleFingerMoveStartsingleFingerMoveProcess。singleFingerMoveStart 中調用了監聽器和 WE 的對應方法,裏面基本沒什麼邏輯。 singleFingerMoveProcess 中也調用了監聽和 WE 的對應方法,可是 WE 的對應方法中更新了 mMoveX 和 mMoveY 的數據。
      • 2.調用 update 更新 WE 中的 view。將 mMode 設置爲 MOVE,表示處於移動中。
    • 3.onSingleTapUp 中首先也是過濾掉了雙指手勢,而後調用了 singleFingerUp 方法:
      • 1.mModeSELECTED_CLICK_OR_MOVE,到這裏的時候才能確認,用戶的行爲是選中了元素以後的點擊,咱們在前面分析過了這裏面的事件分發的機制,這裏也不贅述了。
      • 2.mModeSINGLE_TAP_BLANK_SCREEN,表示點擊 ECV 的空白處,這裏調用的 onClickBlank 也是能夠被子類覆寫的,能夠實現一些本身的邏輯。

    (3).元素雙指手勢以及刪除

    剩下的就交給讀者去閱讀源碼吧,實在是寫不動了,留點精力在最後一章仿寫抖音貼紙控件,那麼下一章見。

3、仿寫一個抖音貼紙控件

最後一章我會基於咱們的控件來模仿抖音的靜態貼紙,固然不會全部細節都還原,但能夠確定的是有些地方咱們的仿製品會作的比抖音好。

一個好消息是,我把 github 中的核心代碼打包上傳到了 JCenter 中,若是讀者想要用這個包只要像使用普通依賴同樣在 build.gradle 文件中添加:implementation 'com.whensunset:sticker:0.2'。這個庫會一直維護,你們能夠多提 issue。先上幾個功能圖吧:

圖2:單指移動,雙指旋轉縮放 水印.gif

圖3:單指旋轉縮放,點擊刪除 水印.gif

圖4:位置輔助線 水印.gif

圖5:垃圾桶 水印.gif

1.特性

這一節來說講咱們的庫中含有的特性吧。

  • 1.單指移動、雙指旋轉縮放、雙指移動:這些功能是 ECV 和 WE 直接就有的功能,抖音也有。
  • 2.選中時的裝飾邊框、單指旋轉縮放、點擊刪除:這些功能是在 DECV 和 DecorationElement 這一層加上的,抖音也有。
  • 3.位置輔助線:這個功能 ins 作的很是好,抖音的這個功能很是爛。因此我是模仿 ins 的,RLECV 支持這個功能。
  • 4.垃圾桶:這個功能 ins 和 抖音都有,ins 的用戶體驗更好,可是能力有限模仿不來 ins,因此模仿了抖音,TECV 支持這個功能。
  • 5.動畫效果:這個功能 ins 和抖音半斤八兩。AnimationElement 是動畫的具體實現類。我在實現的時候 DECV 中添加了一個 onFling 後的滑動效果,仍是挺好玩的,因此咱們仿寫的體驗應該是更好的。

2.仿寫

其實大部分核心代碼都集成到庫中去了,因此咱們只須要寫一點點代碼就能仿寫抖音貼紙的大部分功能,有些地方咱們甚至作得比抖音更好。

咱們的測試代碼在 github 上項目中的 test moudle 中,你們能夠結合代碼來看接下來的分析:

  • 1.正如咱們在前面說的那樣,咱們的庫中含有好幾個不一樣功能的 ECV,從架構圖和上一節的分析中咱們能夠知道 TECV 是繼承結構中最底層的類,裏面包含了咱們上一節中列舉的全部功能。因此咱們在 activity_main 中就能夠用 TECV 來做爲元素的容器 view。
  • 2.佈局定義好了,咱們看 MainActivity 中,這裏有一句很是重要的代碼 Sticker.initialize(this); 它是在使用本框架以前必須調用的方法,裏面會初始化一些東西。這個建議在初始化 App 的時候調用。
  • 3.添加一個 TestElement 咱們在上一章中已經講過了,這裏就不贅述了。咱們看 addStaticElement 這裏點擊會觸發添加一個 StaticStickerElement,這個就是靜態貼紙元素。
  • 4.進入 StaticStickerElement 中查看代碼你會發現很是簡單,由於 StaticStickerElement 用的 view 是 SimpleDraweeView,因此裏面的主要代碼是構造一個 ImageRequest。剩下其餘的東西我已經都實現好了。雖然代碼簡單,可是 StaticStickerElement 不只能夠展現本地圖片,網絡圖片一樣能夠展現。怎麼樣是否是感受這個庫用起來很是簡單,可是效果卻很是好呢?
  • 5.寫到這裏本篇博客也差很少過萬字了,因此庫裏面更多的功能就等着讀者去挖掘了。過一陣子我有時間會在 github 上貼一個本庫的使用文檔,求star、fork、issue。

4、結尾

又是一篇萬字文章,但願你們可以喜歡。最近比較忙,博客更新不會像之前那麼穩定了,望你們多多包涵,但即便再忙個人文章也都會是精心挑選的技術乾貨,不會爲了增長曝光率而亂髮水文和焦慮文。長路漫漫,我們一塊兒進步!

連載文章

不販賣焦慮,也不標題黨。分享一些這個世界上有意思的事情。題材包括且不限於:科幻、科學、科技、互聯網、程序員、計算機編程。下面是個人微信公衆號:世界上有意思的事,乾貨多多等你來看。

世界上有意思的事

參考文獻

相關文章
相關標籤/搜索