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

本文发于简书——何时夕,搬运转载请注明出处,否则将追究版权责任。交流qq群:859640274

GitHub地址

库依赖: implementation 'com.whensunset:sticker:0.2'

近两个月没有更新博客了,感觉已经过气了,哈哈。其实我在准备一个大招,而这个大招准备时间比较长,大家好好期待吧。本篇文章算是大招的前菜,来填补一下这么久没有更新的间隙。当然本篇文章也不是水水而过的,里面的干货非常多,因为我最近几个月的工作内容就和这个相关——story 的文字、贴纸控件。

阅读须知:

  • 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 中的贴纸控件。

一、Story产品技术分析

首先市面上有很多 app 都支持 story 以及类似概念的视频的拍摄和发布。国外的 story 鼻祖是 Ins。国内的微信的时刻视频、多闪的视频拍摄、抖音的随拍等等,都是借鉴了 Ins 的 story。本章的分析也是建立在对上面的四款 app 的分析之上。

1.产品功能分析

下表是我仔细把玩了中外比较有名的可以发布 story 视频的 app 之后的出的结论,下面我们来根据各个产品的功能仔细分析一下。

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 作为基础绘制容器的优劣势:

  • 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 都是采取什么样的方式来显示文字的。我这里也就简单分析一下:

  • 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.也能够进行事件分发,但是应该有点坑,目前我在这种模式下实现不了准确的事件分发,可能是我的实现有问题。

二、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).元素双指手势以及删除

    剩下的就交给读者去阅读源码吧,实在是写不动了,留点精力在最后一章仿写抖音贴纸控件,那么下一章见。

三、仿写一个抖音贴纸控件

最后一章我会基于我们的控件来模仿抖音的静态贴纸,当然不会所有细节都还原,但可以肯定的是有些地方我们的仿制品会做的比抖音好。

一个好消息是,我把 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。

四、结尾

又是一篇万字文章,希望大家能够喜欢。最近比较忙,博客更新不会像以前那么稳定了,望大家多多包涵、但即使再忙我的文章也都会是精心挑选的技术干货,不会只是为了增加曝光率而乱发水文和制造焦虑的文章。长路漫漫,咱们一起前进。

连载文章

参考文献

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,233评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,013评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,030评论 0 241
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,827评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,221评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,542评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,814评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,513评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,225评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,497评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,998评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,342评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,986评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,812评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,560评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,461评论 2 266

推荐阅读更多精彩内容

  • 今天带宝贝们欣赏了梵高的《星空》,看了看平常生活中的星空,还有几张极光 今天主要是运用冷暖色来临创梵高的《星空》,...
    汤圆皮皮虾阅读 427评论 0 0
  • 今天下午接到加班通知,我的第一件事儿就是考虑孩子的作业怎么写!正好他姑姑住陈庄,所以孩子让他姑姑接走!我立马给...
    王本轩阅读 125评论 0 0