Android动态界面开发框架Tangram使用完整教程

阅读本文大概需要20分钟

之前阿里出品的vlayout丰富了RecyclerView的功能,支持各种布局,但是一切都用Java代码实现,不是很灵活,于是提出了Tangram,使用json来配置布局。下面我们来学习一下如何使用Tangram。

目录

1 Tangram的概念

Tangram 是阿里出品的用于快速实现组合布局的框架模型,在手机天猫 Android 及 iOS版广泛使用。


天猫

中文翻译为七巧板,即该框架提供一系列基本单元布局,通过快速拼装就能搭建出一个具备多种布局的页面。

Tangram提供了流式布局、滚动布局,瀑布流布局,固定布局等数种布局样式,布局提供样式参数供调整,布局内部也可填充任意的视图(View),使Native开发的页面具备一定的动态性,并提供极致的性能。

Tangram包含的特点如下:

  • Android iOS 双平台支持,iOS 版本参考开源库 Tangram-iOS
  • 通过 json 创建页面视图,并提供了默认的解析器。
  • 可轻松实现页面视图的回收与复用。
  • 框架提供多种默认的布局方式。
  • 通过 json 数据或代码支持自定义布局样式。
  • 高性能,基于vlayout
  • 支持扩展功能模块

下面来看看如何使用Tangram。

2 Tangram使用步骤

2.1 引入依赖

在APP的build.gradle中添加:

implementation 'com.alibaba.android:tangram:3.3.6@aar'
// we added rxjava in latest version, so need compile rxjava
implementation 'io.reactivex.rxjava2:rxjava:2.1.12'
implementation 'io.reactivex.rxjava2:rxandroid:2.0.2'

其中,Tangram的版本号可以改成最新的,最新版本号可以在这个链接找到:https://github.com/alibaba/Tangram-Android/releases
另外还要添加VirtualViewUltraViewPager这两个库,否则运行时会出现问题:

implementation ('com.alibaba.android:virtualview:1.4.6@aar') {
    transitive = true
}
implementation ('com.alibaba.android:ultraviewpager:1.0.7.7@aar') {
    transitive = true
}

VirtualView的最新版本号可以在这里找到:https://github.com/alibaba/Virtualview-Android/releases

2.2 初始化 Tangram 环境

应用全局只需要初始化一次,需要提供一个图片加载器,例如使用Glide库或Picasso库,代码如下:

TangramBuilder.init(context, new IInnerImageSetter() {
    @Override
    public <IMAGE extends ImageView> void doLoadImageUrl(@NonNull IMAGE view,
                    @Nullable String url) {
        //假设你使用 Picasso 加载图片
        Picasso.with(context).load(url).into(view);
    }
}, ImageView.class);

2.3 初始化 TangramBuilder

在 Activity 中初始化TangramBuilder,假如你的 Activity 是TangramActivity,则代码如下:

TangramBuilder.InnerBuilder builder = TangramBuilder.newInnerBuilder(TangramActivity.this);

2.4 注册自定义的卡片和组件

注册组件的方式有如下3种:

(1)注册绑定组件类型和自定义View,示例代码:

builder.registerCell("type", TestView.class);

(2)注册绑定组件类型、自定义 model、自定义View,示例代码:

builder.registerCell("type", TestCell.class, TestView.class);

(3)注册绑定组件类型、自定义model、自定义ViewHolder,示例代码:

builder.registerCell("type", TestCell.class, new ViewHolderCreator<>(R.layout.item_holder, TestViewHolder.class, TestView.class));

这里先不做详解,关于卡片和组件的详细使用请参见第3节。

2.5 生成 TangramEngine 实例

在上述基础上调用:

TangramEngine engine = builder.build();

2.6 绑定业务 support 类到 engine

Tangram 内部提供了一些常用的 support 类辅助业务开发,具体请见3.3节,使用方式有如下3种:

engine.register(SimpleClickSupport.class, new XXClickSupport());
engine.register(CardLoadSupport.class, new XXCardLoadSupport());
engine.register(ExposureSupport.class, new XXExposureSuport());

2.7 绑定 RecyclerView

engine.bindView(recyclerView);

2.8 监听 RecyclerView 的滚动事件

recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
    @Override
    public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
        super.onScrolled(recyclerView, dx, dy);
        //在 scroll 事件中触发 engine 的 onScroll,内部会触发需要异步加载的卡片去提前加载数据
        engine.onScrolled();
    }
});

2.9 设置悬浮类型布局的偏移(可选)

如果你的 RecyclerView 上方还覆盖有其他 view,比如底部的 tabbar 或者顶部的 actionbar,为了防止悬浮类 view 和这些外部 view 重叠,可以设置一个偏移量。此功能需要额外引入vlayout(https://github.com/alibaba/vlayout)。代码如下:

engine.getLayoutManager().setFixOffset(0, 40, 0, 0);

2.10 设置卡片预加载的偏移量(可选)

在页面滚动过程中会触发engine.onScrolled()方法,会去寻找屏幕外需要异步加载数据的卡片,默认往下寻找5个,让数据预加载出来,我们也可以修改这个偏移量,代码如下:

engine.setPreLoadNumber(3);

2.11 加载数据并传递给 engine

数据一般是调用接口加载远程数据,这里演示的是 mock 加载本地的数据:

byte[] bytes = Utils.getAssertsFile(this, "data.json");
if (bytes != null) {
    String json = new String(bytes);
    try {
        JSONArray data = new JSONArray(json);
        engine.setData(data);
    } catch (JSONException e) {
        e.printStackTrace();
    }
}

2.12 退出的时候销毁 engine

engine.destroy();

通过主动调用 destroy 方法,可以释放内部的资源,比如清理 adapter、清理事件总线缓存的未处理消息、注销广播等。注意调用 destroy 方法之后就不需要调用 unbind 方法了。

3 组件与布局

3.1 页面概念模型

我们将一个普通的列表页面结构化成树状结构:分别是页面、布局(卡片)和组件。一个页面下面可以挂载多个布局或者组件,一个布局下面可以挂载多个组件,整体是一个树状结构描述。每一层次都有各自的职责,如下图:


页面概念模型

3.1.1 页面

如上图所示,一个页面包含一个卡片列表,每个卡片持有一个组件列表,整个页面是一个页面 - 布局 - 组件的树状结构。它要求整体可滚动,并且能按照组件的类型去回收复用。

在实现上,在 Tangram-iOS 里,它是基于 LazyScrollView 的页面容器,在 Tangram-Android 里,它是基于 vlayout 构建的 RecyclerView。

3.1.2 布局(卡片)

布局的主要职责是负责对组件进行布局,它有四个组成:headerfooterbodystyle,如下图所示:

卡片组成

布局结构

最重要的是 body 部分,它包含了内嵌的组件,如果布局没有 body,即没有组件,也就不在视觉上做渲染。卡片的布局也就是对 body 里包含的组件来进行布局。Tangram 内置了一系列布局能力对组件进行布局,包括流式布局、瀑布流布局、吸顶布局、悬浮布局、轮播布局等等,基本上常见的布局方式都可以覆盖到。header、footer 是卡片的标题和尾部,目前只有轮播卡片、通用流式卡片支持 header、footer。style 是对布局样式的描述,所有布局会有一些通用的样式属性比如边距、间距,也有一些特有的比如宽高比,通过样式的描述,可以让布局能力更加丰富。

布局描述就是一种布局类型的声明,因为框架已经内置了布局能力,只需要声明采用哪一种布局方式,因此不需要布局模板。如果框架的内置布局能力满足不了需求,还可以自定义扩展新的布局类型注册到 Tangram 里。

以下是一个布局的 json 描述示例(type, style, header, footer, items都是关键字):

[
  {
    "type": "container-oneColumn", ---> 描述布局类型
    "style": { ---> 描述样式
      ...
    },
    "header": { ---> 描述header
    },
    "items": [ ---> 描述组件列表
      ...
   ],
   "footer": { ---> 描述footer
   }
 },
 ...
]

3.1.3 组件

组件的职责就是负责基本的 UI 展示和交互,它是按照业务划分的最小单元,不像通用的 UI 框架那样会设计文本、按钮、线条那样的基础元素。

在 Tangram 里,组件长什么样,框架是不知道的,框架内也不内置组件,都是由我们接入的时候自行按需注册。同布局一样,组件的数据描述也需要提供与 UI 相关的模板,包含3部分:类型数据样式。类型是必须的,如果我们在 Tangram 里注册过这种类型,那么就能被框架解析处理;数据也是必须的,它包含了业务信息;样式是可选的,组件可以按照自己的需求定义样式,在实现的时候解读样式数据。

以下是一个组件的 json 描述示例(type, style都是关键字):

{
  "type": "demo", ---> 描述组件类型
  "style": { ---> 描述组件样式
    "margin": [
      10,
      10,
      10,
      10
    ],
   "height": 100,
   "width": 100
  }
  "imgUrl": "[URL]", ---> 业务数据
  "title": "Sample"
}

这样,就可以将多个组件的 json 数据放到布局的items里,然后将多个布局的 json 数据组合成一个 json 列表,就形成了一个页面。

下面的步骤,先使用Java进行组件的开发,并进行注册,然后使用json来描述整体的布局。

3.2 组件开发

组件分为两层:model 和 View。model是对json数据的解析,View就是我们自定义的View。Tangram 里提供了通用 model 类型BaseCell,其包含了对json数据的解析,还有位置等信息。开发组件有两种方式:

  1. 采用通用 model,开发自定义 View;
  2. 采用自定义 model 和自定义 View。

下面分别进行介绍。

3.2.1 通用 model 开发组件

这种方式无需关心 model,主要是开发自定义 View。

自定义 View 有两种实现规范:
(1)使用接口方式,避免了反射调用,性能上更优,步骤如下:

  • 实现一个自定义View,比如XXTangramView;
  • 实现接口ITangramViewLifeCycle,包含三个方法:
public void cellInited(BaseCell cell); // 绑定数据前调用
public void postBindView(BaseCell cell); // 绑定数据时机
public void postUnBindView(BaseCell cell); // 滑出屏幕,解除绑定
  • 主要在上述<b>public void postBindView(BaseCell cell)</b>方法里完成组件业务逻辑。

下面实现一个示例,其中包含一个ImageView和一个TextView,代码如下:

public class CustomInterfaceView extends LinearLayout implements ITangramViewLifeCycle {
    private ImageView mImageView;
    private TextView mTextView;

    public CustomInterfaceView(Context context) {
        super(context);
        init();
    }

    public CustomInterfaceView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public CustomInterfaceView(Context context, @Nullable AttributeSet attrs,
                               int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        setOrientation(VERTICAL);
        setGravity(Gravity.CENTER);
        int padding = Utils.dip2px(getContext(), 10);
        setPadding(padding, padding, padding, padding);
        mImageView = new ImageView(getContext());
        addView(mImageView, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
        mTextView = new TextView(getContext());
        mTextView.setPadding(0, padding, 0, 0);
        addView(mTextView, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
    }

    @Override
    public void cellInited(BaseCell cell) {
    }

    @Override
    public void postBindView(BaseCell cell) {
        if (cell.pos % 2 == 0) {
            setBackgroundColor(0xffff0000);
            mImageView.setImageResource(R.mipmap.ic_launcher);
        } else {
            setBackgroundColor(0xff00ff00);
            mImageView.setImageResource(R.mipmap.ic_launcher_round);
        }
        mTextView.setText(String.format(Locale.CHINA, "%s%d: %s", getClass().getSimpleName(),
                cell.pos, cell.optParam("text")));
    }

    @Override
    public void postUnBindView(BaseCell cell) {
    }
}

(2)使用注解方式,动态绑定数据:

  • 实现一个自定义View,比如XXTangramView;
  • 必须添加下面三个方法,以@CellRender注解,功能同上,只是被反射调用:
public void cellInited(BaseCell cell);
public void postBindView(BaseCell cell);
public void postUnBindView(BaseCell cell);
  • 还可以为组件的每个属性实现单独的设置方法,而不是在postBindView方法里一次性绑定数据,这些方法必须以@CellRender注解,框架会在<b>public void postBindView(BaseCell cell)</b>方法调用之前调用这些数据绑定方法,示例代码:
@CellRender(key = "pos") //这里的key=pos表示让框架取原始json数据里pos字段的值传给该方法,原始数据里没有该字段,参数值会是该类型的默认值
 public void setPosition(int pos) {//这里pos的类型要注意,是框架会以该方法声明的类型来取获取原始数据
     textView.setText(cell.id + " pos: " + pos + " " + cell.parent + " " + cell.optParam("msg"));
     if (pos > 57) {
         textView.setBackgroundColor(0x66cccf00 + (pos - 50) * 128);
     } else if (pos % 2 == 0) {
         textView.setBackgroundColor(0xaaaaff55);
     } else {
         textView.setBackgroundColor(0xccfafafa);
     }
 }

下面采用注解的方式实现之前用接口方式实现的自定义View,代码如下:

public class CustomAnnotationView extends LinearLayout {
    private ImageView mImageView;
    private TextView mTextView;

    public CustomAnnotationView(Context context) {
        super(context);
        init();
    }

    public CustomAnnotationView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public CustomAnnotationView(Context context, @Nullable AttributeSet attrs,
                                int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        setOrientation(VERTICAL);
        setGravity(Gravity.CENTER);
        int padding = Utils.dip2px(getContext(), 10);
        setPadding(padding, padding, padding, padding);
        mImageView = new ImageView(getContext());
        addView(mImageView, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
        mTextView = new TextView(getContext());
        mTextView.setPadding(0, padding, 0, 0);
        addView(mTextView, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
    }

    @CellRender
    public void cellInited(BaseCell cell) {
    }

    @CellRender
    public void postBindView(BaseCell cell) {
        if (cell.pos % 2 == 0) {
            setBackgroundColor(0xff0000ff);
            mImageView.setImageResource(R.mipmap.ic_launcher);
        } else {
            setBackgroundColor(0xff00ffff);
            mImageView.setImageResource(R.mipmap.ic_launcher_round);
        }
        mTextView.setText(String.format(Locale.CHINA, "%s%d: %s", getClass().getSimpleName(),
                cell.pos, cell.optParam("text")));
    }

    @CellRender
    public void postUnBindView(BaseCell cell) {
    }
}

以上两种方式开发的组件在页面初始化的时候调用TangramBuilder.InnerBuilder的<b>registerCell(String type, Class<V> viewClz)</b>方法进行注册,也就是2.4节的第一种注册方式,代码如下:

builder.registerCell("InterfaceCell", CustomInterfaceView.class);
builder.registerCell("AnnotationCell", CustomAnnotationView.class);

意思是类型为“InterfaceCell”的组件渲染时会被绑定到CustomInterfaceView的实例上,类型为“AnnotationCell”的组件渲染时会被绑定到CustomAnnotationView的实例上,这种方式注册的组件使用通用的组件模型BaseCell。

在自定义 View 里访问 json 数据:

组件的View对应于一个统一的model,类型是BaseCell,要在View里访问 json 数据,BaseCell提供了以下方法:

public boolean hasParam(String key)
public Object optParam(String key)
public long optLongParam(String key)
public int optIntParam(String key)
public Stirng optStringParam(String key)
public double optDoubleParam(String key)
public boolean optBoolParam(String key)
public JsonObject optJsonObjectParam(String key)
public JsonArray optJsonArrayParam(String key)

这些方法都会先访问BaseCell里持有的原始json数据,同时支持访问style节点下的属性。

例如上面示例代码的第46~47行中使用的代码cell.optParam("text"),它的含义就是取json数据中key为“text”的字段。

3.2.2 自定义 model 开发组件

采用通用的 model 开发组件,只需要写 View 就可以了,然而需要在每次绑定数据的时候都要取原始 json 数据解析一下字段。有时候我们会有一些通用的业务字段定义,每个组件里重复解析会让代码显得冗余,因此也提供了注册自定义 model 的兼容模式开发组件。这个时候就需要写自定义 model 和自定义 View 两部分了。
(1)自定义 model 开发

  • 实现一个自定义 model 类,继承自 BaseCell。
  • 实现以下几个方法:
 /** 解析数据业务数据,可以将解析值缓存到成员变量里 */
 public void parseWith(JSONObject data)
 /** 解析数据样式数据,可以将解析值缓存到成员变量里 */
 public void parseStyle(@Nullable JSONObject data)
 /** 绑定数据到自定义 View */
 public void bindView(@NonNull V view)
 /** 绑定数据到 View 之后,可选实现 */
 public void postBindView(@NonNull V view)
 /** 校验原始数据,检查组件的合法性 */
public boolean isValid()

示例代码:

public class CustomCell extends BaseCell<CustomCellView> {
    private String imageUrl;
    private String text;

    @Override
    public void parseWith(@NonNull JSONObject data, @NonNull MVHelper resolver) {
        try {
            if (data.has("imageUrl")) {
                imageUrl = data.getString("imageUrl");
            }
            if (data.has("text")) {
                text = data.getString("text");
            }
        } catch (JSONException e) {
            e.printStackTrace();
        }
    }

    @Override
    public void bindView(@NonNull CustomCellView view) {
        if (pos % 2 == 0) {
            view.setBackgroundColor(0xffff00ff);
        } else {
            view.setBackgroundColor(0xffffff00);
        }
        view.setImageUrl(imageUrl);
        view.setText(view.getClass().getSimpleName() + pos + ": " + text);
    }
}

(2)自定义 View 开发
类似于3.2.1节,示例代码:

public class CustomCellView extends LinearLayout {
    private ImageView mImageView;
    private TextView mTextView;

    public CustomCellView(Context context) {
        super(context);
        init();
    }

    public CustomCellView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public CustomCellView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        setOrientation(VERTICAL);
        setGravity(Gravity.CENTER);
        int padding = Utils.dip2px(getContext(), 10);
        setPadding(padding, padding, padding, padding);
        mImageView = new ImageView(getContext());
        addView(mImageView, Utils.dip2px(getContext(), 110), Utils.dip2px(getContext(), 72));
        mTextView = new TextView(getContext());
        mTextView.setPadding(0, padding, 0, 0);
        addView(mTextView, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
    }

    public void setImageUrl(String url) {
        Glide.with(this).load(url).into(mImageView);
    }

    public void setText(String text) {
        mTextView.setText(text);
    }
}

这种方式开发的组件在页面初始化的时候调用TangramBuilder.InnerBuilder的<b>registerCell(int type, @NonNull Class<? extends BaseCell> cellClz, @NonNull Class<V> viewClz)</b>方法进行注册,也就是2.4节的第二种注册方式,代码如下:

builder.registerCell("CustomCell", CustomCell.class, CustomCellView.class);

意思是类型为“CustomCell”的组件使用自定义的组件模型CustomCell,在渲染时会被绑定到CustomCellView的实例上。

也可采用2.4节的第三种注册方式,即注册绑定组件类型、自定义model、自定义ViewHolder,示例代码:

builder.registerCell("HolderCell", CustomHolderCell.class,
        new ViewHolderCreator<>(R.layout.item_holder, CustomViewHolder.class, TextView.class));

意思是类型为“HolderCell”的组件使用自定义的组件模型CustomHolderCell,在渲染时以R.layout.item_holder为布局创建类型为TextView的 view,并绑定到类型为CustomViewHolder的 viewHolder 上,组件数据被绑定到TextView的实例上。

CustomHolderCell.java的代码如下:

public class CustomHolderCell extends BaseCell<TextView> {

    @Override
    public void bindView(@NonNull TextView view) {
        if (pos % 2 == 0) {
            view.setBackgroundColor(0xff000fff);
        } else {
            view.setBackgroundColor(0xfffff000);
        }
        view.setText(String.format(Locale.CHINA, "%s%d: %s", getClass().getSimpleName(), pos,
                optParam("text")));
    }
}

item_holder.xml的代码如下:

<?xml version="1.0" encoding="utf-8"?>
<TextView android:id="@+id/title"
          xmlns:android="http://schemas.android.com/apk/res/android"
          android:layout_width="match_parent"
          android:layout_height="130dp"
          android:gravity="center"
          android:textColor="#999999"
          android:textSize="12sp"
          android:textStyle="bold"/>

CustomViewHolder.java的代码如下:

public class CustomViewHolder extends ViewHolderCreator.ViewHolder {
    public TextView textView;

    public CustomViewHolder(Context context) {
        super(context);
    }

    @Override
    protected void onRootViewCreated(View view) {
        textView = (TextView) view;
    }
}

一般情况下,使用上一节和这一节的第一种注册方式这两种方式注册组件即可。

3.2.3 json数据开发

下面针对上面开发的几种组件,根据3.1节的格式写一个简单的json示例。

在项目的assets文件夹下(没有的话自己建一个)新建data.json,代码如下:

[
  {
    "type": "container-oneColumn",
    "items": [
      {
        "type": "InterfaceCell",
        "text": "接口方式自定义View"
      },
      {
        "type": "AnnotationCell",
        "text": "注解方式自定义View"
      },
      {
        "type": "CustomCell",
        "text": "自定义model",
        "imageUrl" : "https://gw.alicdn.com/tfs/TB1vqF.PpXXXXaRaXXXXXXXXXXX-110-72.png"
      },
      {
        "type": "HolderCell",
        "text": "自定义model和ViewHolder"
      }
    ]
  }
]

运行APP,显示效果如下:


3.3 处理业务逻辑

3.3.1 处理点击

此时所有组件可以显示出来了,但是还没有点击事件。组件 View 的点击处理,可以实现SimpleClickSupport,然后在组件自定义的 View 内部调用setOnClickListener(cell);,那么组件的点击行为会被回调到SimpleClickSupport里统一处理。

当然使用SimpleClickSupport只是一种推荐的使用方式,我们选择自定义的点击处理也是可以的。

选用SimpleClickSupport的时候有几个注意点:

  • 建议开启优化模式 —— setOptimizedMode(true),这样点击会被统一回调到<b>public void
    defaultClick(View v, BaseCell cell, int pos)</b>方法里,我们就可以根据组件类型、View的类型即组件类型做点击处理。
  • 如果不开启优化模式,SimpleClickSupport内部会跟进被点击的组件类型和 View 的类型来做路由,它要求我们在SimpleClickSupport的实现类里为每个组件的点击提供以 onClickXXX 或者 onXXXClick 为命名规范的点击处理方法,并且参数列表是View targetView, BaseCell cell, int type或者View targetView, BaseCell cell, int type, Map<String, Object> params。这种方式效率会低一些,针对采用自定义 model 开发组件的时候有用。

那么我们新建一个CustomClickSupport类,代码如下:

public class CustomClickSupport extends SimpleClickSupport {
    public CustomClickSupport() {
        setOptimizedMode(true);
    }

    @Override
    public void defaultClick(View targetView, BaseCell cell, int eventType) {
        Toast.makeText(targetView.getContext(),
                "您点击了组件,type=" + cell.stringType + ", pos=" + cell.pos, Toast.LENGTH_SHORT).show();
    }
}

CustomInterfaceView.javaCustomAnnotationView.java的<b>cellInited()</b>方法中添加如下代码:

public void cellInited(BaseCell cell) {
    setOnClickListener(cell);
}

CustomCell.java的<b>bindView()</b>方法中添加如下代码:

@Override
public void bindView(@NonNull CustomCellView view) {
    ...
    view.setOnClickListener(this);
}

CustomHolderCell.java的<b>bindView()</b>方法中添加如下代码:

@Override
public void bindView(@NonNull TextView view) {
    ...
    view.setOnClickListener(this);
}

最后如2.6节在初始化的时候注册一下,代码如下:

engine.addSimpleClickSupport(new CustomClickSupport());

运行一下APP,点击每个组件,就会弹出toast提示了。

3.3.2 处理曝光

所谓曝光,就是被 RecyclerView 的 Adapter 绑定数据的那个时候,也就是某个Item即将滑动到屏幕范围内的时候。在这个时候业务上如果需要有一些处理,就需要实现ExposureSupport类。它定义了3个层面的曝光接口,一是曝光布局,二是曝光组件整体区域,三是曝光组件局部区域。我们实现它的子类,并针对这三个层面的曝光做分别的实现。分别说明如下:

(1)布局的整体曝光

需要重写回调接口方法public abstract void onExposure(@NonNull Card card, int offset, int position);。新建CustomExposureSupport.java,代码如下:

public class CustomExposureSupport extends ExposureSupport {
    private static final String TAG = "CustomExposureSupport";

    public CustomExposureSupport() {
        setOptimizedMode(true);
    }

    @Override
    public void onExposure(@NonNull Card card, int offset, int position) {
        Log.d(TAG, "onExposure: card=" + card.getClass().getSimpleName() + ", offset=" + offset + ", position=" + position);
    }
}

然后如2.6节在初始化的时候注册一下,代码如下:

engine.addExposureSupport(new CustomExposureSupport());

为了方便说明,我们将data.json改成如下代码:

[
  {
    "type": "container-oneColumn",
    "items": [
      {
        "type": "InterfaceCell",
        "text": "接口方式自定义View"
      },
      {
        "type": "InterfaceCell",
        "text": "接口方式自定义View"
        }
      }
    ]
  },
  {
    "type": "container-oneColumn",
    "items": [
      {
        "type": "AnnotationCell",
        "text": "注解方式自定义View"
      },
      {
        "type": "AnnotationCell",
        "text": "注解方式自定义View"
      }
    ]
  },
  {
    "type": "container-oneColumn",
    "items": [
      {
        "type": "CustomCell",
        "text": "自定义model",
        "imageUrl": "https://gw.alicdn.com/tfs/TB1vqF.PpXXXXaRaXXXXXXXXXXX-110-72.png"
      },
      {
        "type": "CustomCell",
        "text": "自定义model",
        "imageUrl": "https://gw.alicdn.com/tfs/TB1vqF.PpXXXXaRaXXXXXXXXXXX-110-72.png"
      }
    ]
  },
  {
    "type": "container-oneColumn",
    "items": [
      {
        "type": "HolderCell",
        "text": "自定义model和ViewHolder"
      },
      {
        "type": "HolderCell",
        "text": "自定义model和ViewHolder"
      }
    ]
  }
]

运行APP,当我们滚到每一个布局的时候,就会打印一条日志,全部日志如下:

D/CustomExposureSupport: onExposure: card=SingleColumnCard, offset=0, position=0
D/CustomExposureSupport: onExposure: card=SingleColumnCard, offset=0, position=2
D/CustomExposureSupport: onExposure: card=SingleColumnCard, offset=0, position=4
D/CustomExposureSupport: onExposure: card=SingleColumnCard, offset=0, position=6

(2)组件的整体曝光

  • 建议开启优化模式 —— setOptimizedMode(true),这样曝光接口被统一回调到<b>public void defaultExposureCell(@NonNull View targetView, @NonNull BaseCell cell, int type)</b>方法里,我们可以根据组件类型、View的类型即组件类型做曝光处理。
  • 如果不开启优化模式,ExposureSupport内部会跟进被点击的组件类型和 View 的类型来做路由,它要求我们在ExposureSupport的实现类里为每个组件的点击提供以 onExposureXXX 或者 onXXXExposure 为命名规范的点击处理方法,并且参数列表是View targetView, BaseCell cell, int type。这种方式效率会低一些,针对采用自定义 model 开发组件的时候有用。

CustomExposureSupport.java里添加如下代码:

@Override
public void defaultExposureCell(@NonNull View targetView, @NonNull BaseCell cell, int type) {
    Log.d(TAG, "defaultExposureCell: targetView=" + targetView.getClass().getSimpleName() + ", pos=" + cell.pos + ", type=" + type);
}

运行APP,当我们滚到每一个组件的时候,就会打印一条日志,全部日志如下:

D/CustomExposureSupport: defaultExposureCell: targetView=CustomInterfaceView, pos=0, type=0
D/CustomExposureSupport: defaultExposureCell: targetView=CustomInterfaceView, pos=1, type=1
D/CustomExposureSupport: defaultExposureCell: targetView=CustomAnnotationView, pos=0, type=0
D/CustomExposureSupport: defaultExposureCell: targetView=CustomAnnotationView, pos=1, type=1
D/CustomExposureSupport: defaultExposureCell: targetView=CustomCellView, pos=0, type=0
D/CustomExposureSupport: defaultExposureCell: targetView=CustomCellView, pos=1, type=1
D/CustomExposureSupport: defaultExposureCell: targetView=AppCompatTextView, pos=0, type=0
D/CustomExposureSupport: defaultExposureCell: targetView=AppCompatTextView, pos=1, type=1

(3)组件的局部区域曝光

  • 建议开启优化模式 —— setOptimizedMode(true),这样曝光接口被统一回调到<b>public void defaultTrace(@NonNull View targetView, @NonNull BaseCell cell, int type)</b>方法里,我们可以根据组件类型、View的类型即组件类型做曝光处理。
  • 如果不开启优化模式,ExposureSupport内部会跟进被点击的组件类型和 View 的类型来做路由,它要求我们在ExposureSupport的实现类里为每个组件的点击提供以 onTraceXXX 或者 onXXXTrace 为命名规范的点击处理方法,并且参数列表是View targetView, BaseCell cell, int type。这种方式效率会低一些,针对采用自定义 model 开发组件的时候有用。

前两个层面的曝光调用都是框架层调用,而组件局部曝光,则需要我们在组件逻辑里自行调用。使用方式如下:

ExposureSupport exposureSupport = serviceManager.getService(ExposureSupport.class);
if (exposureSupport != null) {
    exposureSupport.onTrace(view, cell, type);
}

那么,我们在CustomInterfaceView.javaCustomAnnotationView.java的<b>cellInited()</b>方法中添加如下代码:

public void cellInited(BaseCell cell) {
    ...
    if (cell.serviceManager != null) {
        ExposureSupport exposureSupport = cell.serviceManager.getService(ExposureSupport.class);
        if (exposureSupport != null) {
            exposureSupport.onTrace(this, cell, cell.type);
        }
    }
}

然后在CustomCell.java的<b>bindView()</b>方法中添加如下代码:

@Override
public void bindView(@NonNull CustomCellView view) {
    ...
    if (serviceManager != null) {
        ExposureSupport exposureSupport = serviceManager.getService(ExposureSupport.class);
        if (exposureSupport != null) {
            exposureSupport.onTrace(view, this, type);
        }
    }
}

CustomHolderCell.java的<b>bindView()</b>方法中添加如下代码:

@Override
public void bindView(@NonNull TextView view) {
    ...
    if (serviceManager != null) {
        ExposureSupport exposureSupport = serviceManager.getService(ExposureSupport.class);
        if (exposureSupport != null) {
            exposureSupport.onTrace(view, this, type);
        }
    }
}

最后在CustomExposureSupport.java里添加如下代码:

@Override
public void defaultTrace(@NonNull View targetView, @NonNull BaseCell cell, int type) {
    Log.d(TAG, "defaultTrace: targetView=" + targetView.getClass().getSimpleName() + ", pos=" + cell.pos + ", type=" + type);
}

运行APP,当我们滚到每一个组件的时候,就会打印一条日志。与(2)不同的是,(2)是在第一次曝光的时候调用,而(3)是在每次曝光的时候都会调用。全部日志如下:

D/CustomExposureSupport: defaultTrace: targetView=CustomInterfaceView, pos=0, type=0
D/CustomExposureSupport: defaultTrace: targetView=CustomInterfaceView, pos=1, type=0
D/CustomExposureSupport: defaultTrace: targetView=CustomAnnotationView, pos=0, type=0
D/CustomExposureSupport: defaultTrace: targetView=CustomAnnotationView, pos=1, type=0
D/CustomExposureSupport: defaultTrace: targetView=CustomCellView, pos=0, type=0
D/CustomExposureSupport: defaultTrace: targetView=CustomCellView, pos=1, type=0
D/CustomExposureSupport: defaultTrace: targetView=AppCompatTextView, pos=0, type=0
D/CustomExposureSupport: defaultTrace: targetView=AppCompatTextView, pos=1, type=0

3.3.3 异步加载数据

有时 Tangram 的页面的数据无法一次性返回,有些区块布局内的数据需要异步加载、甚至分页加载。Tangram 里内置了封装了异步加载的逻辑,需要各个层面配合完成,这里加以说明:

(1)布局model 的load,loadParams,loadType,hasMore

  • load 是接口名称,表示这个布局需要执行异步加载的接口。
  • loadParams 是异步加载接口的常规参数字典,需要在调用接口时透传。
  • loadType 是异步加载的方式,-1表示需要异步加载,1表示需要异步加载且有分页。
  • hasMore 与 loadType 配合,当 loadType = 1 的时候表示分页是否结束。

示例代码:

{
    "id": "Shop",
    "load": "queryShop",
    "loadType": "-1",
    "type": "container-oneColumn"
}

(2)setPreLoadNumber(int preLoadNumber)

调用TangramEngine上的setPreLoadNumber(int preLoadNumber)方法,设置触发卡片预加载的时机,默认 preLoadNumber 是5,表示在滑动过程中,提前去触发可见范围之外5块布局以内的异步加载逻辑。可以通过这个接口调整预加载的范围。

(3)onScrolled()

在recyclerView 的 onScrollListener里调用TangramEngine上的onScrolled()方法,触发预加载的逻辑,代码如2.8节。

(4)CardLoadSupport与AsyncLoader,AsyncPageLoader

在配置完上述异步加载的基础设置之后,提供一个自定义的CardLoadSupport服务,该服务需要提供一个自定义的AsyncLoaderAsyncPageLoader

AsyncLoaderloadData(final Card card, @NonNull final LoadedCallback loadedCallback)方法回调是卡片异步加载的入口。加载完成之后通过LoadedCallback的回写接口告知布局加载是否完成。

AsyncPageLoaderloadData(int page, @NonNull Card card, @NonNull final LoadedCallback callback)方法是回调卡片分页加载的入口。加载完成之后通过LoadedCallback的回写接口告知布局加载是否完成,是否还有下一页。

代码如下:

CardLoadSupport mCardLoadSupport = new CardLoadSupport(new AsyncLoader() {
    @Override
    public void loadData(final Card card, @NonNull final LoadedCallback callback) {
        //...
    }
    }, new AsyncPageLoader() {

    @Override
    public void loadData(int page, @NonNull Card card,
                         @NonNull LoadedCallback callback) {
        //...
    }
});
CardLoadSupport.setInitialPage(1);
engine.addCardLoadSupport(mCardLoadSupport);

AsyncLoader加载成功之后回调示例代码:

List<BaseCell> cells = engine.parseComponent(jsonObject.optJSONArray("items"));
callback.finish(cells);

AsyncPageLoader加载成功之后回调示例代码:

List<BaseCell> cells = engine.parseComponent(jsonObject.optJSONArray("items"));
callback.finish(cells, itemHashMore);

我们就不在demo实现了,有兴趣的读者可以去深入研究。

更多组件高级用法,例如使用定时器等,可参看http://tangram.pingguohe.net/docs/android/use-timer

3.4 布局(卡片)

Tangram的强大之处就在于可以在json中混合各种类型的布局,其主要职责是负责对组件进行布局。

Tangram 内置了一系列布局能力对组件进行布局,包括流式布局、瀑布流布局、吸顶布局、悬浮布局、轮播布局等等,基本上常见的布局方式都可以覆盖到。

下面对各种布局分别进行阐述。

3.4.1 流式(网格)布局

流式布局是最常用的布局。详细说明

type 对应类型
container-oneColumn 单列(一排一)
container-twoColumn 双列
container-threeColumn 三列
container-fourColumn 四列
container-fiveColumn 五列
container-flow N列

3.4.2 一拖N布局

左边一个大的,右边N个小的,可调整比例。详细说明

有三种样式:

  • 左边一个,右边上面一个下面一个
  • 左边一个,右边上面一个下面两个
  • 左边一个,右边上面一个下面三个

会根据数据的数量自动区分

type 对应类型
container-onePlusN 一拖2/3/4

3.4.3 浮动布局

可拖动,自动吸边。详细说明

type 对应类型
container-float 浮标

3.4.4 固定布局

固定在某个位置,不可拖动。详细说明

type 对应类型
container-fix 固定顶部或者底部,根据属性指定
container-scrollFix 滚动固定(滚动到某个布局的时候,出现并固定)

3.4.5 吸顶布局

碰到Tangram的顶端或底端就吸住。详细说明

type 对应类型
container-sticky 吸顶或吸底,根据属性指定

3.4.6 轮播滚动布局

适用于Banner的场景,按页可自动滚动,循环滚动。详细说明

type 对应类型
container-banner 轮播

3.4.7 横向滚动布局

适用于做线性的滚动,而不是Banner一页一页的滚动。详细说明

type 对应类型
container-scroll 线性滚动,不像轮播一样具有一页一页的效果

3.4.8 瀑布流布局

详细说明

type 对应类型
container-waterfall 瀑布流

4 示例

基于上面已经实现的组件,我实现了一个示例,包含了上面列出的所有类型的布局,效果如下:

示例

修改的data.json请见:https://github.com/jimmysuncpt/TangramDemo/blob/master/app/src/main/assets/data.json,大家可以把最后的container-oneColumn去掉,这个是后面用来演示VirtualView的。

为了演示设置背景,我还新增了一个无背景的类型,无背景的自定义View代码请见:https://github.com/jimmysuncpt/TangramDemo/blob/master/app/src/main/java/com/jimmysun/tangramdemo/tangram/NoBackgroundView.java

别忘了在初始化的时候注册一下:

builder.registerCell("NoBackground", NoBackgroundView.class);

完整示例请见:https://github.com/jimmysuncpt/TangramDemo

以上,我们学习了Tangram的概念、使用步骤以及组件与布局的开发。

在 Tangram 体系里,页面结构可以通过配置动态更新,然而业务组件是通过 Java 代码实现的,无法动态更新。为了解决业务组件的动态更新,阿里后来又提出了VirtualView,具体教程可参见Android动态界面开发框架VirtualView使用完整教程

参考链接

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