@IT·互联网Android开发Android技术知识

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

2019-04-15  本文已影响359人  何时夕

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

GitHub地址

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

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

阅读须知:

本文分为以下章节,读者可按需阅读:

一、Story产品技术分析

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

1.产品功能分析

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

Instagram 抖音 多闪 微信
文字 有、功能最丰富 有、功能比较丰富 有、功能比较少 有、功能最少
文字放大 有 emoji 时模糊、无则清晰、放大不卡顿 不模糊、放大卡顿 不模糊、放大卡顿 有点模糊、放大不卡顿
动态贴纸 有、只支持gif、跟手 有、支持视频格式、不跟手 有、支持视频格式、不跟手 有、只支持 gif、跟手
功能贴纸 有、功能丰富 有、功能一般 有、功能一般 有、只有地理位置贴纸
普通贴纸 有、非常跟手 有、不跟手 有、不跟手 有、非常跟手
文字、贴纸是否可相互覆盖 可以 不可以 不可以 可以

2.技术分析

一个功能的诞生过程就是产品和技术相互妥协(撕逼)的过程。所以这一节我就来聊聊上一节中分析的四个 app 体验上达不到尽善尽美的技术原因,也为我们后面的技术实现排坑。

(1).TextureView(SurfaceView)与ViewGroup之争

关注我的同学应该知道我上一篇博客发表的是 SurfaceView家族源码全解析。当我知道要做这个需求的时候其实我第一个想到的是用 TV。因为无论文字也好、贴纸也罢都能被绘制到 Surface 上面,而且性能似乎也不会很差。但是最终的结果是我多加了几天班完全重构了使用 TV 作为基础绘制容器的代码。千言万语汇成一首诗:代码千万行,思考第一行。架构拎不清,加班到天明。那么下面我就来讲讲 TV 和 VG 作为基础绘制容器的优劣势:

(2).如何显示动态贴纸

由前面的对比我们知道,是否支持视频格式的资源与是否跟手有着不可调和的矛盾。ins 和 微信选择了跟手,抖闪则选择了支持视频格式资源。接下来我们就来分析这里面的技术原理与取舍原因

(3).文字的显示方式之争

如果读者看透了(1)和(2)的话,那么我相信你的心里已经非常清楚四种 app 都是采取什么样的方式来显示文字的。我这里也就简单分析一下:

(4).View的缩放位移之争

我们都知道 android 中让 view 变化大小和位置有两种方式,一个是改变 view LayoutParam 中的真实属性,一个是设置 view 的 scale 和 translation。下面我们就来讲讲这两种方式的特点,当然最终我们的实现方案中两种都会有

二、Android端贴纸文字控件架构与实现

1.架构方式

我们第一节先讲讲文字贴纸控件的架构实现,我会基于下面的 图1 和 github 上的代码进行讲解。建议大家把代码 clone 下来,当然别忘了给个 star。

文字贴纸架构.jpg

我们先来根据图1来讲讲整个控件的架构

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 绘制坐标系

(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();
  }

(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));
    }
  }

3.源码流程简析

这一节我主要会通过一个简单的 demo 来讲解一下整个源码的流转过程,让读者读控件整体的运行方式有个简单的了解。这一节主要是讲解源码,所以读者一定要去 clone 源码,跟随文章的脚步前进。

(1).添加元素

(2).元素单指手势

元素手势不像添加元素那样需要外部调用,元素手势是通过事件分发触发的,所以我们可以从 ECV.onTouchEvent 方法入手

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

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

一个好消息是,我把 github 中的核心代码打包上传到了 JCenter 中,如果读者想要用这个包只要像使用普通依赖一样在 build.gradle 文件中添加:implementation 'com.whensunset:sticker:0.2'。这个库会一直维护,大家可以多提 issue。先上几个功能图吧:

图2:单指移动,双指旋转缩放 水印.gif 图3:单指旋转缩放,点击删除 水印.gif 图4:位置辅助线 水印.gif 图5:垃圾桶 水印.gif

1.特性

这一节来讲讲我们的库中含有的特性吧。

2.仿写

其实大部分核心代码都集成到库中去了,所以我们只需要写一点点代码就能仿写抖音贴纸的大部分功能,有些地方我们甚至做得比抖音更好。

我们的测试代码在 github 上项目中的 test moudle 中,大家可以结合代码来看接下来的分析:

四、结尾

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

连载文章

参考文献

上一篇下一篇

猜你喜欢

热点阅读