移动端属性动画低代码方案

6,060 阅读12分钟

什么是属性动画

属性动画系统是一个强健的框架,用于为几乎任何内容添加动画效果。您可以定义一个随时间更改任何对象属性的动画,无论其是否绘制到屏幕上。属性动画会在指定时长内更改属性(对象中的字段)的值。要添加动画效果,请指定要添加动画效果的对象属性,例如对象在屏幕上的位置、动画效果持续多长时间以及要在哪些值之间添加动画效果。

工作原理

首先,让我们通过一个简单的示例来了解动画的工作原理。图 1 描绘了一个假设的对象,该对象的 x 属性(表示其在屏幕上的水平位置)添加了动画效果。动画时长设置为 40 毫秒,要移动的距离为 40 像素。该对象每隔 10 毫秒(这是默认的帧刷新频率)会水平移动 10 像素。在 40 毫秒时,动画停止,同时对象在水平位置 40 处停止。这是使用线性插值(表示对象以恒定速度移动)的动画示例。

类图

官方文档:developer.android.com

为什么要低代码

效率

项目开发中效率都是非常重要的一个指标,同样一个功能别的团队需要一周完成,但你们团队只需要3天,那毫无疑问你们团队的效率就远远高于其他团队,如何提高效率是每个团队都在极力追求的目标,低代码就是提高效率的一个重要方向之一,属性动画在我们日常开发过程中每个动画都需要编写逻辑,人工效率和动画复杂度成正比,开发效率亟待提高。

什么是低代码

这里说的低代码不是现在各种低代码平台的一些概念,有些类似传统观念中的组件化的思路,简单理解其实就是通过一些基础能力的沉淀,尽可能减少开发者代码编写,将重复的工作标准化,标准化带来最直观的就是效率的提高

其实在我们开发过程中都在践行着低代码的原则,比如我们开发一个功能A,逻辑中包含网络请求、图片加载、数据库管理操作等,然后开发一个功能B,逻辑中也有网络请求、图片加载、数据库管理这些模块,前期因为设计经验不足可能都是单独有相关功能就开发相关功能

但随着设计经验的丰富,我们对于相同的逻模块就会考虑抽象出来形成公共组件,比如网络模块、图片加载模块、数据库管理模块等,这其实就是低代码的一个实现

提升

下面的这种设计就是低代码的一个实现,将重复的功能抽象出来提供给后续其他模块直接使用,避免重复造轮子,开发流程就是B组件只需要开发核心功能即可,其他的网络请求、图片加载、数据库管理直接使用公共组件,直接带来的就是团队开发效率的提升

怎么低代码

存在的问题

就属性动画这块来说怎么实现低代码呢?首先我们要找到开发中有哪些流程是重复且耗时的,然后通过设计方案去实现标准化、自动化,减少需要人工参与的流程,这样的方案必然会遵循低代码的原则

我们先来看下目前属性动画的一个工作流

类似这种设计给出的动画说明描述文件相信大家开发过程中应该经常见到

流程中存在的问题

  1. 设计师将动画用文字描述这一步对设计师来说是多余的,徒增无意义工作量
  2. 客户端编码最终都是一堆属性动画接口调用代码,重复且低效
  3. 最终实现效果和设计师设计的有出入需要不断走查调整,重复且低效

要解决上诉的三个问题对方案的要求就有三个

  1. 设计师只管设计无需再用文字描述输出给到开发,去除这一步无意义且耗时的操作
  2. 客户端能自动完成属性代码的组装实现,无需再每个动画都手撸
  3. 设计师所见即所得,100%还原AE设计效果,无需反复走查调整

Lottie的颠覆

看到这里有没有似成相识的感觉?这个不就是Lottie吗?对!Lottie的出现就是为了解决动画开发过程的上述那些问题的。

上述那些我们在做动画开发的时候遇到的问题,Lottie的出现都帮我们解决了,所以Lottie就是一个很好的动画低代码解决方案。

但是Lottie能解决的只是展示型动画,是和业务不相关的纯展示型动画,比如一个跳动的icon,一些新手引导交互动画,这些动画和我们的业务是剥离开来的,无需和业务有关联,这种类型的动画我们都可以交给Lottie来实现

像下面这种:

而如果是和业务相关的组件需要动画则Lottie是无法支持的,比如我们的一个按钮需要有缩放效果,一个卡片需要有渐隐渐现循环显示效果。

Lottie的启发

虽然无法使用Lottie来实现,但是我们可以参考Lottie的方案,来设计我们的属性动画低代码方案,我们先看下Lottie实现原理

Lottie是将AE制作的动画文件最终组合成一个动态的Imageview渲染出来,实现了所见即所得,AE导出动画文件,端侧通过Lottie框架播放动画

如果我们要参考Lottie的方案实现上述属性动画,则对于设计师来说也只是在AE中设计,然后导出动画文件,端侧直接播放动画文件来实现动画效果

基于此我们可以设计属性动画的方案,通过AE插件导出动画数据,然后解析出属性动画相关的数据,再自动封装成对应的属性动画,最后绑定到业务控件上进行播放,从而实现了属性动画的低代码,解决了业务开发中属性动画的痛点问题

核心原理其实相当于AE里面编辑的是动画的模板,然后将模板挂载到控件上,在我们的方案中动画就相当于一个脚本挂件,一个控件可以挂不同的挂件展示不同的动画效果

最终成果

工作流

端侧调用

AnimationManager
            .getInstance()
            .playAnimation(button, "data.json", "btn_scale");

参数说明:

button : 需要播放属性动画的控件,这个控件可以是任何自定义的view

data.json : 动画文件名(同Lottie)

btn_scale : 需要播放的动画,同一个动画文件里面可以有多组动画,这个和lottie有区别

源码解析

简单介绍下源码,核心原理和Lottie一样都是将动画数据转换成可执行渲染逻辑,Lottie是将动画数据转换成可执行帧数据,然后在渲染时候按照数据绘制,我们这个属性动画就是将动画数据转换成可执行属性动画,除了属性解析层逻辑外,底层的解析以及工具类都是参考的Lottie源码,可以理解为站在Lottie的肩膀上扩展出的我们这个方案。(源码都做了删减)

动画文件

先贴一下动画文件的数据结构方便大家有个清晰的认识,下面一段是AE通过bodymovin导出的动画文件精简版

{
  "v": "5.7.8",
  "fr": 60,
  "ip": 0,
  "op": 180,
  "w": 400,
  "h": 200,
  "nm": "测试",
  "ddd": 0,
  "assets": [],
  "layers": [
    {
      "ddd": 0,
      "ind": 4,
      "ty": 4,
      "nm": "btn_scale", //动画名称,对应api里面的animationName
      "sr": 1,
      "ks": {  //关键帧数据,我们需要解析的
        "o": { //o 标识透明度变换
          ...
        },
        "r": { //r 标识旋转变换
          ...
        },
        "p": { //p 位移变换
          ...
        },
        "a": { // 锚点变换,这个属性里面可以忽略
          ...
        },
        "s": { //缩放变换
          "a": 1,
          "k": [ //关键帧数据
            {
              "i": {
                "x": [],
                "y": []
              },
              "o": {
                "x": [],
                "y": []
              },
              "t": 0, //开始帧
              "s": [ //对应的数据,这里是缩放下面的则s[0]标识x缩放s[1]标识y缩放
                100,
                100,
                100
              ]
            },
            {
              "i": {
                "x": [],
                "y": []
              },
              "o": {
                "x": [],
                "y": []
              },
              "t": 30, //第二个关键帧
              "s": [
                110,
                110,
                100
              ]
            },
          ],
        }
      },
      ...
    }
  ]
}

源码逻辑简介

1. 读取动画文件

传入动画文件名称需要本地解析成属性数据存到内存中,因此需要对资源进行一些IO操作

相关类:

  1. AnimationManager  管理动画入口

  2. AnimationViewWrapper 包装动画组件

  3. AttributeCompositionFactory 动画资源读取

核心逻辑入口,提供两个方法一个是播放默认动画,一个是播放制定动画,一个动画文件里面可以包含多组动画,通过动画名称区分

public class AnimationManager {

  private final static String DEFAULT_ANIMATION = "animation";
  /**
   * 播放默认动画
   * @param target 目标控件
   * @param assetName 动画资源名称
   */
  public DynamicComponent playAnimation(View target, String assetName) {
    return playAnimation(target, assetName, DEFAULT_ANIMATION);
  }

  /**
   * 播放指定名称动画
   * @param target 目标控件
   * @param assetName 动画资源名称
   * @param animationName 动画名称
   */
  public DynamicComponent playAnimation(View target, String assetName, String animationName) {
    DynamicComponent viewWrapper = new AnimationViewWrapper(target, assetName, animationName);
    viewWrapper.playAnimation();
    return viewWrapper;
  }
}

动画控件的一个包装类,实现动态组件的一些方法包含动画的一些控制流程

/**
 * 初始化
 * @param target 绑定的控件
 * @param assetName 动画文件名称
 * @param animationName 动画名称
 */
public AnimationViewWrapper(final View target, final String assetName, String animationName) {
  this.target = target;
  this.assetName = assetName;
  this.animationName = animationName;
  setCompositionTask(fromAssets(assetName));
}

/**
 * 从asset里面加载资源
 * @param assetName 动画资源名称
 * @return AnimationTask
 */
private AnimationTask<AttributeComposition> fromAssets(final String assetName) {
  return cacheComposition ?
      AttributeCompositionFactory.fromAsset(target.getContext(), assetName) :
    AttributeCompositionFactory.fromAsset(target.getContext(), assetName, null);
  }
}

属性组件工厂类,提供通过本地文件&网络创建AnimationTask,以及缓存逻辑

private static AnimationResult<AttributeComposition> fromJsonReaderSyncInternal(
      JsonReader reader, @Nullable String cacheKey, boolean close) {
  ...
  //属性解析 核心逻辑
  AttributeComposition composition = AttributeCompositionParser.parse(reader);
  if (cacheKey != null) {
    AttributeCompositionCache.getInstance().put(cacheKey, composition);
  }
  return new AnimationResult<>(composition);
  ...
}

  
/**
 * 缓存处理
 * @param cacheKey 缓存key 
 * @param callable
 * @return AnimationTask
 */
private static AnimationTask<AttributeComposition> cache(
  @Nullable final String cacheKey, Callable<AnimationResult<AttributeComposition>> callable) {
  ......
  return task;
}

2. 分层解析

AE输出的动画是按图层归类的,需要我们将对应的图层动画信息解析并分类存储

相关类:

  1. AttributeCompositionParser 最外层疏忽解析

  2. AttributeLayerLayerParser 层关键帧数据解析

第一层解析类,解析第一层数据

public static AttributeComposition parse(JsonReader reader) throws IOException {
  ......
    reader.beginObject();
  while (reader.hasNext()) {
    switch (reader.selectName(NAMES)) {
      case 0:
      case 1:
        reader.nextInt();
        break;
      case 2:
      case 3:
        reader.nextDouble();
        break;
      case 4:
        frameRate = (float) reader.nextDouble();
        break;
      case 5:
        String version = reader.nextString();
        break;
      case 6:
        //所有的属性动画都在这个层级下面
        parseLayers(reader, attributeAnimationInfoMap);
        break;
      default:
        reader.skipName();
        reader.skipValue();
    }
  }
  //将帧数据转换成时间
  frameConvertToTime(frameRate, attributeAnimationInfoMap);
  //初始化composition
  composition.init(frameRate, attributeAnimationInfoMap);

  return composition;
 }

解析核心ks关键帧层数据

public static AttributeLayer parse(JsonReader reader) throws IOException {

  ......
    reader.beginObject();
  while (reader.hasNext()) {
    switch (reader.selectName(NAMES)) {
      case 0:
        layerName = reader.nextString();
        break;
      case 1:
        layerId = reader.nextInt();
        break;
      case 2:
        refId = reader.nextString();
        break;
      case 3:
        //属性数据解析核心逻辑
        transform = AttributeTransformParser.parse(reader);
        break;
      default:
        reader.skipName();
        reader.skipValue();
    }
  }
  reader.endObject();

  return new AttributeLayer(layerName, layerId, refId, transform);
}

3. 属性变换层解析

AE输出的是动画中的转换操作数据,需要转换成我们端侧需要的属性数据

相关类:

  1. AttributeTransformParser 属性转换数据解析

  2. AttributeValueParser 属性解析基类

  3. AlphaValueParser 透明度属性解析

  4. TranslationValueParser 位移属性解析

  5. RotationValueParser 旋转属性解析

  6. ScaleValueParser 缩放属性解析

public static AttributeTransform parse(JsonReader reader) throws IOException {
  while (reader.hasNext()) {
    switch (reader.selectName(NAMES)) {
      case 0: // p 解析位移变换
        translationInfos = AttributeValueParser.parseTranslation(reader);
        break;
      case 1: // s 解析缩放变换
        scaleInfos = AttributeValueParser.parseScale(reader);
        break;
      case 2: // r 解析旋转变换
        rotationInfos = AttributeValueParser.parseRotation(reader);
        break;
      case 3: // o 解析透明度变换
        alphaInfos = AttributeValueParser.parseAlpha(reader);
        break;
      default:
        reader.skipName();
        reader.skipValue();
    }
  }
  return new AttributeTransform(translationInfos, rotationInfos, alphaInfos, scaleInfos);
}

4. 生成关键帧数组

AE输出的是关键帧动画信息,我们需要将关键帧信息转换成属性数据组

5. 修改帧信息为毫秒单位

AE输出的单位是帧索引,需要将帧索引转换成时间单位毫秒

6. 组装属性动画

根据帧数组新,封装成动画列表

7. 播放动画

获取封装好的动画列表进行播放

/**
 * 获取AnimatorSet列表
 * @param target
 * @param animation
 * @return List<AnimatorSet>
 */
public List<AnimatorSetWrapper> getAnimatorSetList(final View target, final String animation) {

  AttributeTransform transform = attributeAnimationInfoMap.get(animation);
  if (transform == null) {
    return null;
  }
  List<AnimatorSetWrapper> animatorSets = new ArrayList<>();
  //解析缩放变换
  AnimatorSetParserHelper.parseScale(target, animatorSets, transform);

  //解析旋转变换
  AnimatorSetParserHelper.parseRotation(target, animatorSets, transform);

  //解析透明度变换
  AnimatorSetParserHelper.parseAlpha(target, animatorSets, transform);

  //解析平移变换
  AnimatorSetParserHelper.parseTranslation(target, animatorSets, transform);

  //按动画时间排序
  Collections.sort(animatorSets, new Comparator<AnimatorSetWrapper>() {
    @Override
    public int compare(AnimatorSetWrapper o1, AnimatorSetWrapper o2) {
      return o2.getDuration() - o1.getDuration();
    }
  });
  return animatorSets;
}


/**
 * 开始动画
 */  
public void startAnimation() {
  for (AnimatorSetWrapper setWrapper : animatorSetList) {
    AnimatorSet animatorSet = setWrapper.getAnimatorSet();
    if (setWrapper.getAnimatorSet().isRunning()) {
      animatorSet.cancel();
    }
    animatorSet.start();
  }
}

结语

生产力的提高是我们程序员最期盼的目标,好钢要用在刀刃上,我们要拒绝重复且无意义的工作,只有这样才有更多的时间用来提高自己,因此在工作中我们要直面效率瓶颈,发现问题不要妥协,不要绕开,更不要让自己陷入低效的循环中去,我们要把问题抛出来,大家一起讨论优化的方案,就如同本篇文章提到的属性动画对于移动端来说就是重复且无意义的工作,写1000行和写10行对技术能力并没有任何提高,浪费的只是我们宝贵的学习时间,希望这边文章能够给你的工作中带来一些帮助,有疑问欢迎留言一起讨论,谢谢!

hi, 我是快手电商的HD

快手电商无线技术团队正在招贤纳士🎉🎉🎉! 我们是公司的核心业务线, 这里云集了各路高手, 也充满了机会与挑战. 伴随着业务的高速发展, 团队也在快速扩张. 欢迎各位高手加入我们, 一起创造世界级的电商产品~

热招岗位: Android/iOS 高级开发, Android/iOS 专家, Java 架构师, 产品经理(电商背景), 测试开发... 大量 HC 等你来呦~

内部推荐请发简历至 >>>我们的邮箱: hr.ec@kuaishou.com <<<, 备注我的花名成功率更高哦~ 😘