Android | Tangram 动态页面之路

原文链接:
1、Android | Tangram动态页面之路(一)需求背景
2、Android | Tangram动态页面之路(二)介绍
3、Android | Tangram动态页面之路(三)使用
4、Android | Tangram动态页面之路(四)vlayout原理
5、Android | Tangram动态页面之路(五)Tangram原理
6、Android | Tangram动态页面之路(六)数据分离
7、Android | Tangram动态页面之路(七)硬核的Virtualview

本系列文章主要介绍天猫团队开源的Tangram框架的使用心得和原理,由于Tangram底层基于vlayout,所以也会简单讲解,该系列将按以下大纲进行介绍:

(一)需求背景

首先,笔者在工作中(生产环境)使用的并非Tangram,而是公司内部的框架(未开源),不过原理都大同小异,本系列文章也不会精细到每一行源码,不求齐全,只求用尽可能小的篇幅讲明白。

页面定投

大家都知道,电商行业喜欢造节,从双11双12,到现在的321、618、921等,几乎一年四季都会有营销活动,这些活动能带动GMV持续造血。一场大促,通常会分预热期和正式期。预热期用来造势,着重透出主会场、活动等内容;正式期则在接近尾声时,着重透出倒计时内容增强紧迫感(再不剁手就没机会了),如下图:

img

可以看出,从预热期到正式期,着重透出的内容不同,结构也不同。也就是说,需要足够灵活的页面模板,满足不同时间,不同人群(如多人多面)展示不同结构的页面。当然这点h5也能做到,但是h5体验要差于native,一般用于临时活动页或高度灵活的页面。像商城首页,商品详情等相对稳定的页面,对灵活性的要求并非无限制的,只要各模块足够抽象、细粒度,native也是可以满足日常和大促需求的。

数据聚合

如前边的商城首页,数据来源也是多渠道的,比如头部的banner、各类入口、类目模块,数据来源于一个个不同的资源位,由不同的人进行配置,而尾部的商品流数据则来源于推荐引擎,如下图,

img

客户端不可能去发起多个请求拉取不同资源位的数据,所以就有了聚合层接口,客户端通过一个聚合接口,获取聚合数据,

img

商品流不属于资源位数据,所以独立成一个接口。至此,我们会发现,页面模板不仅要描述页面结构,还要描述各模块所需的数据来源。大概如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"template":[
{
"component":"banner", //轮播图模块
"data":"makeup:banner" //数据来源于聚合接口,key为banner
},
{
"component":"category", //类目模块
"data":"makeup:category" //数据来源于聚合接口,key为category
},
{
"component":"goods", //商品流模块
"data":"request:recommend" //数据来源于request,key为recommend
}
]
}

RecyclerView的异构和扁平

要实现前边商城首页的复杂布局,通常的思路是定义各种itemType,然后根据itemType来解析不同的布局文件,这么做往往需要进行一层嵌套,因为RecyclerView自带的LayoutManager不支持如此异构的布局,效果如下图左,

img

而使用vlayout,可以免去这层嵌套,如上图右,让子view成为RecyclerView的直接子view,具体我们运行vlayout的官方Demo,然后使用AS的Tools - Layout inspector功能查看view树,可见图中的39和43两个小模块都是RecyclerView的直接子view,有着平级关系,

img

这就是用RecyclerView实现异构和扁平化的基本概念,vlayout通过自定义LayoutManager进行了实现。关于异构和扁平,更详细的分析可见参考文章。

参考文章

  • 苹果核 - RecyclerView 里的自定义 LayoutManager 的一种设计与实现

(二)介绍

vlayout

因为Tangram底层基于vlayout,所以需要先了解下vlayout

首先,在view上的性能消耗通常有以下几种:

  • 布局嵌套导致多重measure/layout

可以使用ConstraintLayoutRelativeLayout减少布局嵌套

  • view的频繁创建与销毁

列表使用RecyclerView来复用布局

  • xml转换成view解析过程产生的内存和耗时

如果列表的样式不多,使用RecyclerView的复用机制可以避免大量的xml解析;如果样式比较多比如商品图墙等,则有必要把xml解析提前到编译期,在编译期根据注解将xml转成对应的view类,直接使用view类创建viewHolder,当然这么做会势必会增大包体积,需要克制使用

然后,vlayout主要解决前两点,做到复杂布局下扁平和细粒度复用。

常规的RecyclerView使用:

1
2
3
4
5
// 设置适配器,管理数据源和view
recyclerView.setAdapter()
// 设置LayoutManager,指定布局方式
recyclerView.setLayoutManager()

其中LayoutManager有3种,

1
2
3
4
LinearLayoutManager extends LayoutManager  //线性
GridLayoutManager extends LinearLayoutManager //网格
StaggeredGridLayoutManager extends LayoutManager //瀑布流

在面对比较复杂的布局时,如1拖3样式,

img

通常只能在1拖3外边套上一层layout,然后使用LinearLayoutManager实现。为了解决这个问题,

vlayout自定义了一个VirtualLayoutManager,它继承自 LinearLayoutManager;引入了 LayoutHelper 的概念,它负责具体的布局逻辑;VirtualLayoutManager管理了一系列LayoutHelper,将具体的布局能力交给LayoutHelper来完成,每一种LayoutHelper提供一种布局方式,框架内置提供了几种常用的布局类型,包括:网格布局、线性布局、瀑布流布局、悬浮布局、吸边布局等。这样实现了混合布局的能力,并且支持扩展外部,注册新的LayoutHelper,实现特殊的布局方式。

引用自苹果核 - Tangram 的基础 —— vlayout(Android)

大致意思是这样,

img

vlayoutDemo中,使用代码是这样,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//VLayoutActivity.java

//子适配器集合
List<DelegateAdapter.Adapter> adapters = new LinkedList<>();

//创建子适配器,需指定其布局方式
SubAdapter subAdapter1 = new SubAdapter(new LinearLayoutHelper());
adapters.add(subAdapter1);
SubAdapter subAdapter2 = new SubAdapter(new ColumnLayoutHelper());
adapters.add(subAdapter2);

//把子适配器集合设置给代理适配器
delegateAdapter.setAdapters(adapters);

recyclerView.setAdapter(delegateAdapter);

可以看到,随着布局样式越来越多,代码量也会越来越多,于是,用json模板描述页面Tangram诞生了。

Tangram

vlayout直接给到业务方使用,这样的接入成本是不能接受的,于是需要屏蔽掉vlayout细节,让业务方用的更舒服。至于为何要引入json模板需求背景里已经做过介绍。

img

Tangram的意思是七巧板,旨在用七巧板的方式拼凑出各式各样的页面。他抽象了两个概念,CardCellCard用于描述布局方式,Cell用于描述在这个布局方式下,用什么样的view去展示,比如TangramDemo里的data.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[
{
"type": "container-oneColumn", //Card,布局方式
"items": [
{
"imgUrl": "https://gw.alicdn.com/tfs/TB1vqF.PpXXXXaRaXXXXXXXXXXX-110-72.png",
"arrowImgUrl": "https://gw.alicdn.com/tfs/TB1vqF.PpXXXXaRaXXXXXXXXXXX-110-72.png",
"title": "标题1",
"type": "vvtest" //Cell,具体展示的view
},
{
"imgUrl": "https://gw.alicdn.com/tfs/TB1vqF.PpXXXXaRaXXXXXXXXXXX-110-72.png",
"arrowImgUrl": "https://gw.alicdn.com/tfs/TB1vqF.PpXXXXaRaXXXXXXXXXXX-110-72.png",
"title": "标题2",
"type": "vvtest"
}
]
}
]

这些布局方式Card,在Tangram内部会进行注册,

1
2
3
4
5
6
7
8
//TangramBuilder.java
void installDefaultRegistry(final DefaultResolverRegistry registry) {
// built-in cards
registry.registerCard(TYPE_CONTAINER_BANNER, BannerCard.class);
registry.registerCard(TYPE_SINGLE_COLUMN_COMPACT, SingleColumnCard.class);
//...
}

布局方式确定好后,需要具体的View来展示,也就是Cell,比如单图SingleImageView,纯文本RatioTextView等等,这些Cell则需手动注册,如果是偏业务的Cell,可以在业务层按需注册,如果是更抽象的通用Cell,则应该下沉到基础库里全局注册,更抽象的Cell意味着需要提供更为通用的配置属性,能提供给更多不同的业务方使用。Cell的手动注册如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//TangramActivity.java
void onCreate(Bundle savedInstanceState) {
//Step 3: register business cells and cards
builder.registerCell(1, TestView.class);
builder.registerCell(2, SimpleImgView.class);

//绑定recyclerView
engine.bindView(recyclerView);

JSONArray data = new JSONArray(new String(getAssertsFile(this, "data.json")));
//设置json数据
engine.setData(data);
}

CardCell都注册好了,通过TangramEngine将数据设置进去,跟进去可以看到,Tangram把布局能力交给了vlayout

1
2
3
4
5
6
//GroupBasicAdapter extends VirtualLayoutAdapter
void setData(List<L> cards, boolean silence) {
//把Card转成LayoutHelper
setLayoutHelpers(transformCards(cards, mData, mCards));
}

Tangram把json模板中描述的一个个Card解析成了所对应的vlayout的布局方式LayoutHelper

img

值得注意的是,TangramDemo里的json模板都是包含了业务数据的,这么做可能是为了剔除掉网络请求的代码,方便开源学习,而在实际业务中不太可能把数据绑定在模板里,这样模板会很臃肿,我们要做的是,用模板描述页面结构和数据源,而非数据本身,如,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"template":[
{
"card": "container-banner", //轮播图模块,使用banner布局方式
"cell":"imageView", //具体的view就是一张图片
"data":"makeup:banner" //数据来源于聚合接口,key为banner
},
{
"card": "container-fiveColumn", //类目模块,使用Column布局方式
"cell":"imageAndTextView", //具体的view是上图下文本
"data":"makeup:category" //数据来源于聚合接口,key为category
},
{
"card": "container-waterfall", //商品流模块,使用Staggered布局方式
"cell":"goodsView", //具体的view是商品样式
"data":"request:recommend" //数据来源于request,key为recommend
}
]
}

既然json模板可以由后端下发,也就意味着,我们可以让运营同学通过后台拖动模块搭建页面,选择资源位设置数据源,然后生成json模板下发,开发同学从此就可以解放双手,做更有趣(更有挑战)的事情了。

img

不过这都是后话了,对Tangramvlayout的介绍就到这里了,下篇文章将对Tangram的使用进行更详细的讲解,点关注,不迷路~

参考文章

  • 苹果核 - Tangram 的基础 —— vlayout(Android)
  • 苹果核 - 页面动态化的基础 —— Tangram

(三)使用

Demo代码

基础使用

引入依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//tangram相关:tangram使用3.0之前的最新版本,其他直接使用最新版本
implementation 'com.alibaba.android:tangram:2.2.5@aar'
//tangram底层支持:vlayout
implementation 'com.alibaba.android:vlayout:1.2.36@aar'
//tangram虚拟视图(更灵活的视图,后面单独开篇讲)
implementation('com.alibaba.android:virtualview:1.4.6@aar') {
transitive true
}
//tangram支持banner翻页用的
implementation 'com.alibaba.android:ultraviewpager:1.0.7.8@aar'
//tangram内部需要rxjava
implementation 'io.reactivex.rxjava2:rxjava:2.1.12'
implementation 'io.reactivex.rxjava2:rxandroid:2.0.2'

初始化,主要是传递上下文进去,和设置图片加载能力,这里我们使用Glide

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class MyApp extends Application {
@Override
public void onCreate() {
super.onCreate();
TangramBuilder.init(this, new IInnerImageSetter() {
@Override
public <IMAGE extends ImageView> void doLoadImageUrl(@NonNull IMAGE view, @Nullable String url) {
Glide.with(view.getContext()).load(url).
error(R.mipmap.ic_launcher).
into(view);
}
}, NetImageView.class);
}
}

在activity中使用,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//MainActivity.java
void onCreate(Bundle savedInstanceState) {
//创建builder来配置参数
TangramBuilder.InnerBuilder builder = TangramBuilder.newInnerBuilder(this);
//注册自己的cell
builder.registerCell(ImageTextView.class.getSimpleName(), ImageTextView.class);
builder.registerCell(SingleImageView.class.getSimpleName(), SingleImageView.class);

//创建引擎
mEngine = builder.build();
//绑定RecyclerView
mEngine.bindView(mBinding.rvList);

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

//设置数据,触发渲染
String file = FileUtil.getAssertsFile(this, "main.json");
try {
mEngine.setData(new JSONArray(file));
} catch (JSONException e) {
e.printStackTrace();
}
}

前边的介绍篇提到过,Tangram内置了一些布局方式Card,基本满足需求了,所以我们只需定制自己的具体View也就是Cell即可,上边手动注册了两个CellImageTextViewSingleImageView,我们先来看ImageTextView,他是一个LinearLayout,上边一个图标,下边一个文本,需要实现ITangramViewLifeCycle接口,在相应的回调里执行自己的逻辑,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class ImageTextView extends LinearLayout implements ITangramViewLifeCycle {
private NetImageView mImgIcon;
private TextView mTvTitle;

public ImageTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
setOrientation(VERTICAL);
setGravity(Gravity.CENTER);
inflate(getContext(), R.layout.cell_image_text, this);
mImgIcon = findViewById(R.id.img_icon);
mTvTitle = findViewById(R.id.tv_title);
}

@Override
public void cellInited(BaseCell cell) {
}

@Override
public void postBindView(BaseCell cell) {
mImgIcon.load(cell.optStringParam("imgUrl"));
mTvTitle.setText(cell.optStringParam("title"));
}

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

其布局文件cell_image_text.xml如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content">

<com.holiday.tangram.view.NetImageView
android:id="@+id/img_icon"
android:layout_width="50dp"
android:layout_height="50dp"
android:scaleType="centerCrop"
tools:background="@color/colorAccent" />

<TextView
android:id="@+id/tv_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="1"
tools:text="@string/app_name" />
</merge>

Cell都准备好了,下面就是准备json模板了,先来一个四列布局container-fourColumn

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"type": "container-fourColumn",
"items": [
{
"imgUrl": "https://tva1.sinaimg.cn/large/007S8ZIlgy1geqp9zhftrj303r03r3yl.jpg",
"title": "标题1",
"type": "ImageTextView"
},
{
"imgUrl": "https://tva1.sinaimg.cn/large/007S8ZIlgy1geqp9zhftrj303r03r3yl.jpg",
"title": "标题2",
"type": "ImageTextView"
},
{
"imgUrl": "https://tva1.sinaimg.cn/large/007S8ZIlgy1geqp9zhftrj303r03r3yl.jpg",
"title": "标题3",
"type": "ImageTextView"
},
{
"imgUrl": "https://tva1.sinaimg.cn/large/007S8ZIlgy1geqp9zhftrj303r03r3yl.jpg",
"title": "标题4",
"type": "ImageTextView"
}
]
}

运行如下,

img

下面再来看另一个自定义CellSingleImageView很简单,就是单图,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class SingleImageView extends NetImageView implements ITangramViewLifeCycle {

public SingleImageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
setScaleType(ScaleType.CENTER_CROP);
}

@Override
public void cellInited(BaseCell cell) {
}

@Override
public void postBindView(BaseCell cell) {
load(cell.optStringParam("imgUrl"));
}

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

然后来一个1拖n布局container-onePlusN

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"type": "container-onePlusN",
"style": {
"aspectRatio": "1.778",
"margin": "[10,0,0,0]"
},
"items": [
{
"imgUrl": "https://wanandroid.com/blogimgs/942a5c62-ca87-4e7c-a93d-41ff59a15ba4.png",
"type": "SingleImageView"
},
{
"imgUrl": "https://www.wanandroid.com/blogimgs/62c1bd68-b5f3-4a3c-a649-7ca8c7dfabe6.png",
"type": "SingleImageView"
},
{
"imgUrl": "https://www.wanandroid.com/blogimgs/50c115c2-cf6c-4802-aa7b-a4334de444cd.png",
"type": "SingleImageView"
},
{
"imgUrl": "https://www.wanandroid.com/blogimgs/90c6cc12-742e-4c9f-b318-b912f163b8d0.png",
"type": "SingleImageView"
}
]
}

图片取自玩安卓的banner图,运行如下,

img

因为有4条数据,所以展示的效果就是1拖3。

然后再来个轮播图布局container-banner,他的Cell还是使用单图SingleImageView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
{
"type": "container-banner",
"style": {
"margin": "[0,0,10,0]",
"pageWidth": 200,
"pageHeight": 100,
"indicatorMargin": "5",
"infinite": "true",
"indicatorImg2": "https://img.alicdn.com/tps/TB1XRNFNXXXXXXKXXXXXXXXXXXX-32-4.png",
"indicatorImg1": "https://img.alicdn.com/tps/TB16i4qNXXXXXbBXFXXXXXXXXXX-32-4.png",
"scrollMarginLeft": "10",
"indicatorGap": "2",
"indicatorHeight": "1.5",
"itemRatio": "2.654",
"scrollMarginRight": "10",
"indicatorGravity": "center",
"hGap": "20"
},
"items": [
{
"imgUrl": "https://wanandroid.com/blogimgs/942a5c62-ca87-4e7c-a93d-41ff59a15ba4.png",
"type": "SingleImageView"
},
{
"imgUrl": "https://www.wanandroid.com/blogimgs/62c1bd68-b5f3-4a3c-a649-7ca8c7dfabe6.png",
"type": "SingleImageView"
},
{
"imgUrl": "https://www.wanandroid.com/blogimgs/50c115c2-cf6c-4802-aa7b-a4334de444cd.png",
"type": "SingleImageView"
},
{
"imgUrl": "https://www.wanandroid.com/blogimgs/90c6cc12-742e-4c9f-b318-b912f163b8d0.png",
"type": "SingleImageView"
}
]
}

可以看出可供配置的参数还是很多的,文档可以看这里,运行如下,

img

整体效果如下,

img

img

内置support支持

Tangram内置了一些support支持,如处理点击SimpleClickSupport,卡片数据加载CardLoadSupport,曝光逻辑ExposureSupport等,可以通过TangramEngineadd方法注册,如下,

1
2
3
//MainActivity.java
mEngine.addSimpleClickSupport(new MyClickSupport());

然后看MyClickSupport

1
2
3
4
5
6
7
8
9
10
11
12
public class MyClickSupport extends SimpleClickSupport {
public MyClickSupport() {
setOptimizedMode(true);
}

@Override
public void defaultClick(View targetView, BaseCell cell, int eventType) {
super.defaultClick(targetView, cell, eventType);
QrToast.show(cell.stringType);
}
}

我们可以在自己的Cell里对view设置点击事件,也可以把点击事件交给support全局处理,如果要使用support处理点击事件,需要在Cell里加这行代码,

1
2
3
4
5
6
//SingleImageView.java
@Override
public void cellInited(BaseCell cell) {
setOnClickListener(cell);
}

比如商城首页,大多数模块都是点击进行页面跳转,行为比较单一,不需要每个Cell都去做点击事件,SimpleClickSupport就能很好的支持,在defaultClick里取出页面短链进行跳转即可,如,

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MyClickSupport extends SimpleClickSupport {
public MyClickSupport() {
setOptimizedMode(true);
}

@Override
public void defaultClick(View targetView, BaseCell cell, int eventType) {
super.defaultClick(targetView, cell, eventType);
//取出页面短链,进行跳转。短链既可以是h5的,也可以是native的,由Router内部处理
PageRouter.to(cell.optStringParam("link"));
}
}

用起来很简单有木有!好啦,Tangram的使用就介绍到这里了,后面会分析原理,实现模板和数据分离等等,敬请期待~

img

参考文章

  • GitHub-Tangram使用文档
  • Tangram官网

(四)vlayout原理

基于vlayout最新源码

Tangram和vlayout介绍这篇文章提到过,

vlayout自定义了一个VirtualLayoutManager,它继承自 LinearLayoutManager;引入了 LayoutHelper 的概念,它负责具体的布局逻辑;VirtualLayoutManager管理了一系列LayoutHelper,将具体的布局能力交给LayoutHelper来完成,每一种LayoutHelper提供一种布局方式,框架内置提供了几种常用的布局类型,包括:网格布局、线性布局、瀑布流布局、悬浮布局、吸边布局等。这样实现了混合布局的能力,并且支持扩展外部,注册新的LayoutHelper,实现特殊的布局方式。

引用自苹果核 - Tangram 的基础 —— vlayout(Android)

大致意思是这样,

img

VLayoutActivity中,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//VLayoutActivity.java
void onCreate(Bundle savedInstanceState) {
if (FLOAT_LAYOUT) {
//创建布局方式layoutHelper,FloatLayoutHelper是浮动可拖拽布局,比如微信现在的浮窗功能
FloatLayoutHelper layoutHelper = new FloatLayoutHelper();
//设置初始位置为右下角
layoutHelper.setAlignType(FixLayoutHelper.BOTTOM_RIGHT);
//设置偏移量,位置是右下角时,分别是marginRight和marginBottom
layoutHelper.setDefaultLocation(100, 400);
//设置宽高
LayoutParams layoutParams = new LayoutParams(150, 150);
//创建子适配器,添加进适配器集合
adapters.add(new SubAdapter(this, layoutHelper, 1, layoutParams));
}
}

来到子适配器SubAdapter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//继承DelegateAdapter.Adapter
class SubAdapter extends DelegateAdapter.Adapter<MainViewHolder> {
private LayoutHelper mLayoutHelper;

public SubAdapter(Context context, LayoutHelper layoutHelper, int count, LayoutParams layoutParams) {
this.mContext = context;
this.mLayoutHelper = layoutHelper;
this.mCount = count;
this.mLayoutParams = layoutParams;
}

@Override
public LayoutHelper onCreateLayoutHelper() {
//把传进来的布局方式LayoutHelper返回
return mLayoutHelper;
}

//创建ViewHolder
public MainViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
return new MainViewHolder(LayoutInflater.from(mContext).inflate(R.layout.item, parent, false));
}

//绑定ViewHolder
protected void onBindViewHolderWithOffset(MainViewHolder holder, int position, int offsetTotal) {
((TextView) holder.itemView.findViewById(R.id.title)).setText(Integer.toString(offsetTotal));
}
}

delegateAdapter.setAdapters(adapters)时,取出适配器指定的布局方式,进行透传,

1
2
3
4
5
6
7
8
9
10
//DelegateAdapter.java
public void setAdapters(List<Adapter> adapters) {
List<LayoutHelper> helpers = new LinkedList<>();
for (Adapter adapter : adapters) {
LayoutHelper helper = adapter.onCreateLayoutHelper();
helpers.add(helper);
}
super.setLayoutHelpers(helpers);
}

来到VirtualLayoutManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//VirtualLayoutManager.java
void setLayoutHelpers(@Nullable List<LayoutHelper> helpers) {
//设置每个布局方式LayoutHelper的管辖范围start和end
//假设第1个模块是ColumnLayoutHelper,有3个元素,则管辖范围是[0,2]
//第2个模块是OnePlusNLayoutHelper,有4个元素,则管辖范围是[3,6]
if (helpers != null) {
int start = 0;
Iterator<LayoutHelper> it1 = helpers.iterator();
while (it1.hasNext()) {
LayoutHelper helper = it1.next();
if (helper.getItemCount() > 0) {
helper.setRange(start, start + helper.getItemCount() - 1);
} else {
helper.setRange(-1, -1);
}
start += helper.getItemCount();
}
}
//内部进行赋值和排序,RangeLayoutHelperFinder可以根据位置查找对应的LayoutHelper
this.mHelperFinder.setLayouts(helpers);
requestLayout();
}

LayoutHelper被赋值好后,进行布局,这里暂不深究View的测量布局绘制流程,来到VirtualLayoutManager.onLayoutChildren

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//VirtualLayoutManager.java
void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
//预布局,也就是调用每个ayoutHelper的beforeLayout
runPreLayout(recycler, state);
super.onLayoutChildren(recycler, state);
}

//ExposeLinearLayoutManagerEx.java
void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
fill(recycler, mLayoutState, state, false);
}

int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
RecyclerView.State state, boolean stopOnFocusable) {
layoutChunk(recycler, state, layoutState, layoutChunkResultCache);
}

//VirtualLayoutManager.java
void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,
LayoutState layoutState,
com.alibaba.android.vlayout.layout.LayoutChunkResult result) {
//RangeLayoutHelperFinder根据位置查找对应的的布局方式LayoutHelper
final int position = layoutState.mCurrentPosition;
LayoutHelper layoutHelper = mHelperFinder == null ? null : mHelperFinder.getLayoutHelper(position);
layoutHelper.doLayout(recycler, state, mTempLayoutStateWrapper, result, this);
}

//BaseLayoutHelper.java
void doLayout(RecyclerView.Recycler recycler, RecyclerView.State state,
LayoutStateWrapper layoutState, LayoutChunkResult result,
LayoutManagerHelper helper) {
//触发每个具体的LayoutHelper进行测量和布局
layoutViews(recycler, state, layoutState, result, helper);
}

具体的测量和布局的实现layoutViews,我们举两个比较典型的布局方式分析,ColumnLayoutHelperFloatLayoutHelper

举例ColumnLayoutHelper列布局

设置比重,第一列和第四列占比33,中间两列不指定比重,则平分剩余空间,

1
2
layoutHelper.setWeights(new float[]{33f, Float.NaN, Float.NaN, 33f});

效果如下,

img

来看layoutViews方法,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//ColumnLayoutHelper.java
void layoutViews(RecyclerView.Recycler recycler, RecyclerView.State state,
VirtualLayoutManager.LayoutStateWrapper layoutState,
LayoutChunkResult result, LayoutManagerHelper helper) {
final int count = getAllChildren(mViews, recycler, layoutState, result, helper);
//1. 计算每个child的margin

//2. 用总宽度和百分比为child分配宽高,没有设置百分比的child先存储进mEqViews
for (int i = 0; i < count; i++) {
View view = mViews[i];
VirtualLayoutManager.LayoutParams params = (VirtualLayoutManager.LayoutParams) view.getLayoutParams();
int heightSpec = helper.getChildMeasureSpec(
helper.getContentHeight() - helper.getPaddingTop() - helper.getPaddingBottom(),
uniformHeight > 0 ? uniformHeight : params.height, true);
if (mWeights != null && i < mWeights.length && !Float.isNaN(mWeights[i]) && mWeights[i] >= 0) {
//根据百分比计算宽度
int resizeWidth = (int) (mWeights[i] * 1.0f / 100 * availableWidth + 0.5f);
//根据宽度和比例计算高度
if (!Float.isNaN(params.mAspectRatio)) {
int specialHeight = (int) (resizeWidth / params.mAspectRatio + 0.5f);
heightSpec = View.MeasureSpec
.makeMeasureSpec(specialHeight, View.MeasureSpec.EXACTLY);
}
helper.measureChildWithMargins(view, View.MeasureSpec.makeMeasureSpec(resizeWidth, View.MeasureSpec.EXACTLY), heightSpec);
//记录已使用宽度
usedWidth += resizeWidth;
//记录最小高度
minHeight = Math.min(minHeight, view.getMeasuredHeight());
} else {
mEqViews[eqSize++] = view;
}
}
}

3.将剩余宽度平分给没有设置百分比的child,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//ColumnLayoutHelper.java
for (int i = 0; i < eqSize; i++) {
View view = mEqViews[i];
VirtualLayoutManager.LayoutParams params = (VirtualLayoutManager.LayoutParams) view.getLayoutParams();
int heightSpec;
int resizeWidth = (int) ((availableWidth - usedWidth) * 1.0f / eqSize + 0.5f);
//根据宽度和比例计算高度
if (!Float.isNaN(params.mAspectRatio)) {
int specialHeight = (int) (resizeWidth / params.mAspectRatio + 0.5f);
heightSpec = View.MeasureSpec
.makeMeasureSpec(specialHeight, View.MeasureSpec.EXACTLY);
} else {
heightSpec = helper.getChildMeasureSpec(
helper.getContentHeight() - helper.getPaddingTop() - helper.getPaddingBottom(),
uniformHeight > 0 ? uniformHeight : params.height, true);
}
helper.measureChildWithMargins(view, View.MeasureSpec.makeMeasureSpec(resizeWidth, View.MeasureSpec.EXACTLY),
heightSpec);
//记录最小高度
minHeight = Math.min(minHeight, view.getMeasuredHeight());
}

4.为所有child统一高度,为最小高度

1
2
3
4
5
6
7
8
9
//ColumnLayoutHelper.java
for (int i = 0; i < count; i++) {
View view = mViews[i];
if (view.getMeasuredHeight() != minHeight) {
helper.measureChildWithMargins(view, View.MeasureSpec.makeMeasureSpec(view.getMeasuredWidth(), View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(minHeight, View.MeasureSpec.EXACTLY));
}
}

5.测量完成,进行布局,最终交给RecyclerView.LayoutManager进行处理,即layoutDecorated

1
2
3
4
5
6
7
8
9
//ColumnLayoutHelper.java
for (int i = 0; i < count; i++) {
View view = mViews[i];
int top = mTempArea.top, bottom = mTempArea.bottom;
int right = left + orientationHelper.getDecoratedMeasurementInOther(view);
layoutChildWithMargin(view, left, top, right, bottom, helper);
left = right;
}

举例FloatLayoutHelper浮动可拖拽布局

FloatLayoutHelper的布局代码就不看了,大概就是根据位置和偏移量计算具体位置,我们重点关注下他的触摸事件实现,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//FloatLayoutHelper.java
View.OnTouchListener touchDragListener = new View.OnTouchListener() {
boolean onTouch(View v, MotionEvent event) {
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
isDrag = false;
//按下,让父view RecyclerView不要拦截事件
(v.getParent()).requestDisallowInterceptTouchEvent(true);
lastPosX = (int) event.getX();
lastPosY = (int) event.getY();
break;
case MotionEvent.ACTION_MOVE:
if (Math.abs(event.getX() - lastPosX) > mTouchSlop
|| Math.abs(event.getY() - lastPosY) > mTouchSlop) {
isDrag = true;
}
if (isDrag) {
//...
//不断更新坐标,实现移动效果
v.setTranslationX(curTranslateX);
v.setTranslationY(curTranslateY);
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
//抬起或取消,播放吸边动画,即自动弹回两侧
doPullOverAnimation(v);
//让父view RecyclerView恢复拦截事件
(v.getParent()).requestDisallowInterceptTouchEvent(false);
break;
}
}
}

效果如下,

img

RecyclerView复用和Cantor函数

RecyclerView最终使用的是管理子适配器集合的DelegateAdapter,通常情况下,我们是没法保证各个子适配器间的viewType能不冲突的,所以这里只分析hasConsistItemType=false的情况,具体原因见FAQ(组件复用的问题),

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//DelegateAdapter.java

@Override
public int getItemViewType(int position) {
Pair<AdapterDataObserver, Adapter> p = findAdapterByPosition(position);
//子适配器的viewType作为subItemType
int subItemType = p.second.getItemViewType(position - p.first.mStartPosition);
//布局方式LayoutHelper的所在位置作为index
int index = p.first.mIndex;
//Cantor运算转成一个数
return (int) Cantor.getCantor(subItemType, index);
}

@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
//Cantor逆运算,把一个数转回subItemType和index
Cantor.reverseCantor(viewType, cantorReverse);
int index = (int)cantorReverse[1];
int subItemType = (int)cantorReverse[0];
//根据index找到具体的子适配器
Adapter adapter = findAdapterByIndex(index);
//由子适配器来创建具体的view
return adapter.onCreateViewHolder(parent, subItemType);
}

这边有点晦涩,画了张图,需要细品~

img

这样,自然就可以利用RecyclerView自带的复用机制帮我们管理view的复用了,

关于cantor函数:

设idx1,type1;idx2,type2,

当 idx1 != idx2 或 type1 != type2,

viewType1 = cantor(idx1,type1)

viewType2 = cantor(idx2,type2) 时

满足 viewType1 != viewType2

同时支持逆运算:

viewType1 => idx1,type1

viewType2 => idx2,type2

感兴趣的话可以看vlayout中使用数学的小场景

img

参考文章

  • 苹果核 - Tangram 的基础 —— vlayout(Android)
  • 苹果核 - Pairing Function —— vlayout 中使用数学的小场景
  • 博客园-基于场景解析RecyclerView的回收复用机制原理

(五)Tangram原理

基于Tangram最新源码分析

笔者Demo代码

Tangram和vlayout介绍这篇文章提到过,Tangram通过解析json模板得到布局方式Card和具体视图Cell,然后将Card转换成对应的vlayoutLayoutHelper来进行测量和布局,如下,

img

官网的架构图如下,

img

Card转成LayoutHelper

跟进TangramActivityengine.setData(data)

1
2
3
4
5
6
7
8
9
10
11
//BaseTangramEngine.java
void setData(T data) {
//模板解析,json文件 -> JSONArray -> List<Card>
List<C> cards = mDataParser.parseGroup(data, this);
this.setData(cards);
}

void setData(List<C> data) {
this.mGroupBasicAdapter.setData(data);
}

来到GroupBasicAdapter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
//GroupBasicAdapter.java
void setData(List<L> cards, boolean silence) {
//把cards转成vlayout的layoutHelpers
setLayoutHelpers(transformCards(cards, mData, mCards));
if (!silence)
notifyDataSetChanged();
}

//cards指json模板中的多个布局方式card,
//data指每个card里边的具体视图cell
//rangeCards指一段管辖范围内所对应的布局方式card
//假设第1个card对应ColumnLayoutHelper,有3个元素,则管辖范围是[0,2]
//第2个card对应OnePlusNLayoutHelper,有4个元素,则管辖范围是[3,6],以此类推
List<LayoutHelper> transformCards(List<L> cards, List<C> data,
List<Pair<Range<Integer>, L>> rangeCards) {
//data.size()初始值为0
int lastPos = data.size();
List<LayoutHelper> helpers = new ArrayList<>(cards.size());
for (int i = 0, size = cards.size(); i < size; i++) {
//遍历每个card
L card = cards.get(i);
//获取card的类型,如列布局container-fourColumn
final String ctype = getCardStringType(card);
//获取card内的cell数组
List<C> items = getItems(card);
//如果card里边没有cell,即没有视图,直接跳过
if (items == null) {
continue;
}
//记录每个card里边的多个cell
data.addAll(items);
int offset = lastPos;
lastPos += items.size();
//记录每一段管辖范围,和其对应的card
rangeCards.add(Pair.create(Range.create(offset, lastPos), card));
//获取card对应的LayoutHelper,暂不深究
LayoutBinder<L> binder = mCardBinderResolver.create(ctype);
LayoutHelper helper = binder.getHelper(ctype, card);
if (helper != null) {
//设置cell个数
helper.setItemCount(items.size());
helpers.add(helper);
}
}
return helpers;
}

Card被转换成LayoutHelper

img

转换完成后,调用了notifyDataSetChanged,是如何显示到RecyclerView上的呢?

RecyclerView展示

跟进TangramActivityengine.bindView(recyclerView)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//BaseTangramEngine.java
void bindView(@NonNull final RecyclerView view) {
this.mContentView = view;
//设置VirtualLayoutManager
this.mContentView.setLayoutManager(mLayoutManager);
//设置性能监控,mLayoutManager负责监控cell的耗时
mLayoutManager.setPerformanceMonitor(mPerformanceMonitor);
if (mGroupBasicAdapter == null) {
this.mGroupBasicAdapter = mAdapterBuilder.newAdapter(mContext, mLayoutManager, this);
//设置性能监控,mGroupBasicAdapter负责监控card和cell的耗时
mGroupBasicAdapter.setPerformanceMonitor(mPerformanceMonitor);
//错误报告
mGroupBasicAdapter.setErrorSupport(getService(InternalErrorSupport.class));
}
if (mContentView.getRecycledViewPool() != null) {
//设置RecyclerView缓存池,InnerRecycledViewPool装饰了RecycledViewPool
mContentView.setRecycledViewPool(new InnerRecycledViewPool(mContentView.getRecycledViewPool()));
}
//注册服务,暂不深究
register(GroupBasicAdapter.class, mGroupBasicAdapter);
register(RecyclerView.RecycledViewPool.class, mContentView.getRecycledViewPool());
//设置适配器
this.mContentView.setAdapter(mGroupBasicAdapter);
}

可见RecyclerView设置的适配器是GroupBasicAdapter,看下我们比较关心的几个方法,

1
2
3
4
5
6
7
8
9
//GroupBasicAdapter.java

int getItemViewType(int position) {
C data = mData.get(position);
//内部缓存了Map<String, Integer> mStrKeys
//String就是cell名字如SingleImageView,Integer就是一系列从0开始递增的ViewType
return getItemType(data);
}

官方Demo早期用了int来声明Cell,这样容易混乱,不利于在json模板里表意,现在改成了String来声明(为此还做了些兼容代码),建议直接使用String来注册,可参考Tangram的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"id": "banner1",
"type": "container-oneColumn",
"style": {
"aspectRatio": 3.223
},
"items": [
{
"bizId":"item1",
"type": 110, //不要再使用int声明cell,建议使用唯一字符串如SingleImageView
"msg": "info1"
},
{
"bizId":"item2",
"type": 110, //不要再使用int声明cell,建议使用唯一字符串如SingleImageView
"msg": "info2"
}
]
}

然后看下onCreateViewHolderonBindViewHolder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//GroupBasicAdapter.java

BinderViewHolder<C, ? extends View> onCreateViewHolder(ViewGroup parent, int viewType) {
//根据viewType得到cell名字
String cellType = getCellTypeFromItemType(viewType);
//大概是通过cellType帮我们创建对应的view,暂不深究
ControlBinder<C, ? extends View> binder = mCompBinderResolver.create(cellType);
//一个普通的ViewHolder,提供了bind方法
BinderViewHolder binderViewHolder = createViewHolder(binder, mContext, parent);
return binderViewHolder;
}

void onBindViewHolder(BinderViewHolder<C, ? extends View> holder, int position) {
//获取cell
C data = mData.get(position);
//绑定cell
holder.bind(data);
}

//省略调用链:
//BinderViewHolder.bind -> BaseCellBinder.mountView -> MVHelper.mountView
// -> MVHelper.postMountView -> ITangramViewLifeCycle.postBindView

//回调到业务层,如TestView.java
void postBindView(BaseCell cell) {
//业务逻辑
textView.setText("xxx");
}

至此,整个流程就跑通了。

img

参考文章

  • Tangram官网-基础架构

(六)数据分离

笔者Demo代码,内容见demo2包。

前面的文章提到过,在实际业务中不太可能把数据绑定在模板里,这样模板会很臃肿,我们要做的是,用模板描述页面结构和数据源,而非数据本身,因此需要将数据剥离出来。

运行效果:

img

数据mock自玩安卓(看着有点乱,后续有时间搭个小服务,向业务贴近),

img

重点看页面结构即可,远程模板调整了Card顺序、4列布局改成5列、改了文本颜色和瀑布流item背景色。

然后来看该页面实现ShoppingHomeAct

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class ShoppingHomeAct extends TangramActivity {
ActivityShoppingHomeBinding mBinding;

@Override
protected void onCreate(Bundle savedInstanceState) {
mBinding = DataBindingUtil.setContentView(this, R.layout.activity_shopping_home);
super.onCreate(savedInstanceState);
}

@Override
protected String createBizDomain() {
return "shopping_home";//返回业务域:商城首页
}

@Override
protected RecyclerView createRecyclerView() {
return mBinding.rvList;//返回RecyclerView
}

@Override
protected boolean needRefreshAndLoadMore() {
return true;//开启下拉刷新和加载更多
}
}

代码很少,只需继承具备Tangram能力的TangramActivity,返回其需要的对象即可。

动态合并数据

参考官方Demo,首先想到的方法是,动态来合并数据,也就是按如下思路,

img

进行数据准备,

  1. 模板地址:net_shopping_home.json
  2. 聚合数据接口:tangram/shopping/home
  3. 瀑布流数据接口:玩安卓 - article/list/0/json

模板如下(有删减),描述了页面结构和数据源,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
{
//聚合数据接口,当然实际业务中不需要写完整路径,如tangram/shopping/home
"requestMakeup":"http://rest.apizza.net/mock/3f233eed2d9be716a5f48fccb9c719f2/tangram/shopping/home",
//瀑布流数据接口
"requestList":"https://www.wanandroid.com/article/list/%s/json",
//模板名字
"templateName":"net_shopping_home",
//页面结构template
"template":[
{
"type":"container-fiveColumn", //五列布局
"load":"makeup:category", //数据源是聚合接口的category字段
"itemType":"ImageTextView", //具体视图cell是上图下文ImageTextView
"style":{
"textColor":"#6699ff", //扩展字段,文本颜色
"padding":[
9,
9,
0,
9
]
}
},
{
"type":"container-waterfall", //瀑布流布局
"itemType":"GoodsItemView", //具体视图cell是商品样式GoodsItemView
"load":"xxx", //不用写,只要配置了requestList,默认最后一个Crad取瀑布流数据
"style":{
"column":2, //展示两列
"hGap":"4", //间隙
"vGap":"4",
"margin":[
9,
9,
0,
9
],
"itemBgColor":"#1F1F1F", //扩展字段,item背景颜色
"textColor":"#ffffff" //扩展字段,文本颜色
}
}
]
}

聚合数据如下(有删减),

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"errorCode":0,
"errorMsg":"",
"data":{
"banner":[ //轮播图数据
{
"imgUrl":"https://wanandroid.com/blogimgs/942a5c62-ca87-4e7c-a93d-41ff59a15ba4.png",
"link":"https://www.wanandroid.com/navi"
},
{
"imgUrl":"https://www.wanandroid.com/blogimgs/62c1bd68-b5f3-4a3c-a649-7ca8c7dfabe6.png",
"link":"https://www.wanandroid.com/blog/show/2"
},
{
"imgUrl":"https://www.wanandroid.com/blogimgs/50c115c2-cf6c-4802-aa7b-a4334de444cd.png",
"link":"https://flutter.cn/"
}
],
"bottomTitle":[ //瀑布流标题数据
{
"title":"猜你喜欢"
}
]
}
}

一切准备就绪,开始实现TangramActivity,关注主要实现即可。

把聚合数据合并进模板对象的template字段,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//TangramActivity.java
void mergeMakeupDataToTemplate(JSONObject data, JSONArray template) throws JSONException {
//遍历每一个卡片(布局),把数据填充进字段items
for (int i = 0; i < template.length(); i++) {
JSONObject card = template.getJSONObject(i);
//如果card有load字段,并且字段值是makeup:开头,表示card的数据源为聚合数据
if (card.has("load") && card.getString("load").startsWith("makeup:")) {
String load = card.getString("load");
JSONArray cells = data.getJSONArray(load.substring(load.indexOf(":") + 1));
//把模板配置的itemType即具体视图cell写进数据源
for (int cellIdx = 0; cellIdx < cells.length(); cellIdx++) {
cells.getJSONObject(cellIdx).put("type", card.getString("itemType"));
}
card.put("items", cells);
}
}
}

解析瀑布流数据,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//TangramActivity.java
void parseListData(List<ArticleBean.DataBean.Article> list, @NonNull Card card) {
JSONArray cells = new JSONArray();
try {
for (int i = 0; i < list.size(); i++) {
JSONObject obj = new JSONObject(MyApp.gson.toJson(list.get(i)));
obj.put("type", card.optStringParam("itemType"));
//由于使用了玩安卓的数据结构,这里手动添加一些参数用于演示
obj.put("imgUrl", DataUtil.getImgByIdx(i + mListDataPage * list.size()));
cells.put(obj);
}
} catch (JSONException e) {
e.printStackTrace();
}
List<BaseCell> cs = mEngine.parseComponent(cells);
card.addCells(cs);
card.notifyDataChange();
finishLoad();
}

完整代码可见TangramActivity.java

待解决问题

  • 局部刷新问题,暂时无解。之前有小伙伴提到过tangram不支持局部刷新,然后实践了一下,处理起来确实挺棘手,如加载瀑布流数据后,card.notifyDataChange的本质还是notifyDataSetChanged
  • 优化成非继承TangramActivity实现。让业务Activity继承实现始终不够灵活,尝试包装一下核心引擎TangramEngine

(七)硬核的Virtualview

GitHub地址:

  • GitHub - Virtualview-Android
  • GitHub - virtualview_tools

需求背景一文介绍了模块化搭建页面的由来,那有没有想过这样一种场景,有天产品灵光一闪,想要不发版把上图下文换成上文下图,又或者想要在每个图片右上角加个双11大促角标来营造氛围,由于客户端只预埋了上图下文的样式(以下简称cell),即ImageTextView,所以只能延期到下一班车,

img

很显然,即便我们根据当下的业务抽象了一些常用的Cell,比如上图下文纯文本单图等,而且还支持了一些通用的属性配置如文本大小颜色等,也无法满足多变的业务需求,也即cell不够用了,我们要有线上生产cell并下发的能力。所以,VirtualView诞生了。

VirtualView的核心思想是,编写xml样式文件,编译压缩成二进制文件,下发到客户端,客户端解析,转成native view,或者用canvas绘制。引用官方的一张图片,

img

因此,当UI有细节变动时,只需要修改xml,然后编译好下发给客户端替换即可。不过,我们的生产环境用的是另外一套基于flexbox-layout的方案而非VirtualView,本文是站在学习的角度进行调研。

img

框架名字积木七巧板,可见,相似的业务场景,衍生出了相似的技术方案。

img

VirtualView很赞的两点是,他的二进制压缩实时预览,接下来进行详细分析。

二进制压缩

通过 XML 编写的业务组件,如果直接加载解析,会有几个问题:一是原始文件相对较大,因为 XML 里会有冗余信息,如空格、换行、还有重复出现的字符串等,文件体积比较大;二是解析 XML 会有一定开销,相对于二进制数据直接解析,XML 解析会比较重,例如节点遍历、属性访问等都显得有些臃肿。通过提前将 XML 模板处理成二进制格式,可以将繁重的解析工作从客户端运行时中剥离出来,而通过将一些重复的资源做合并处理并建立索引,可以减少冗余信息,减少模板文件大小,通常情况下,处理成二进制格式的模板比原始模板可减少 50% - 60% 的大小。

引用自苹果核 - VirtualView Android实现详解(一)—— 文件格式与模板编译

先来看一个简单的xml样式文件,直接把他下发到客户端存在两个问题,一是冗余字符引起的带宽浪费,二是客户端解析耗时和内存,在用户手机内存吃紧时,面对一个样式繁多的RecyclerView时,即便存在复用机制也可能因解析引起oom(来自电商的痛),往往需要在编译期就把xml转成view类,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<VHLayout xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="../../vv.xsd"
orientation="V"
layoutWidth="match_parent"
layoutHeight="200"
background="#11000000">

<NText text="${text}"
textSize="30"
textColor="@{${items[0].info.textColor} ? ${items[1].subItems[0].info.textColor} : ${items[2].subItems[0].info.textColor}}"
background="#fffff0"
layoutWidth="match_parent"
layoutHeight="200"
gravity="h_center|v_center" />

</VHLayout>

连Android自带的XmlPullParser解析都足够重了,那我们能不能避开这个思路呢?来看看VirtualView的思路,首先看到virtualview_tools工程,在virtualview_tools/compiler-tools/RealtimePreview/config.properties文件中,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 把内置支持的view映射成int1000以内
VIEW_ID_FrameLayout=1
VIEW_ID_NImage=9
VIEW_ID_VImage=10
// 1000以上给外部自定义的view
VIEW_ID_TotalContainer=1010

// 定义枚举映射,即xml里写的rowrow-reverse也会被转成int
flexDirection=Enum<row:0,row-reverse:1,column:2,column-reverse:3>
orientation=Enum<H:1,V:0>

// 定义一些属性值的类型
borderWidth=Float
itemWidth=Number

在进行类型的简化后,约定一种数据格式,每一块分别展示什么信息,如下,

img

比如,开头有版本区,后面有组件区组件长度区字符串区字符串长度区表达式区表达式长度区…这有点像JVM校验解析字节码的过程。一些资源的映射处理,如下,

  • 颜色:转换成4字节整型颜色值,格式 AARRGGBB;
  • 枚举:按照预定义的整数转换,比如 gravity 的类型,orientation 的类型;
  • 字符串:以 hashCode 值作为它的序列化后整数,并在字符串资源区建立以 hashCode 为索引的列表,在解析的时候从中获取原始的字符串值;
  • 逻辑表达式:与字符串的处理类似;
  • 数字:直接转换成 4 字节的整型或者浮点型,并支持带单位的类型;

引用自苹果核 - VirtualView Android实现详解(一)—— 文件格式与模板编译

字符串用hashCode值为索引的列表方案,可以节省重复字符串的空间,表达式是用来绑定动态数据如${text}

得到二进制数据,

img

把二进制数据下发到客户端,在Virtualview-Android工程中,可以看到一个BinaryLoader类,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//BinaryLoader.java

//二进制数据,转成byte数组进行读取
public int loadFromBuffer(byte[] buf, boolean override) {
CodeReader reader = new CodeReader();

reader.setCode(buf);
reader.seekBy(Common.TAG.length());

// check version
int majorVersion = reader.readShort(); //读取主版本号
int minorVersion = reader.readShort(); //读取副版本号
int patchVersion = reader.readShort(); //读取修订版本号
reader.setPatchVersion(patchVersion);

int uiStartPos = reader.readInt(); //读取UI开始位置
reader.seekBy(4);

int strStartPos = reader.readInt(); //读取字符串开始位置
reader.seekBy(4);

int exprCodeStartPos = reader.readInt(); //读取表达式开始位置
reader.seekBy(4);
}

这样,把xml样式文件压缩成二进制文件,既节省了带宽,又免去了客户端比较重的XmlPullParser解析,真是快乐Double~

img

原生控件和虚拟控件

VirtualView翻译成中文就是虚拟视图,因为他里边有个虚拟控件的概念。可以看到它里边有些控件有两份,分别是V和N开头的,如VImageNImageVTextNText

V开头指的是Virtual View虚拟视图,即不需要实际的ImageViewTextView,而是在一个Container(如ViewGroup)内,直接拿他的画布canvas进行内容绘制,如drawTextdrawBitmap等操作;

N开头指的是Native View即原生视图,需要实际的ImageViewTextView来承载。

看下截图更直观,

Virtual View

img

Native View

img

虚拟视图跟原生视图相比会更轻量,当然具体还得结合业务使用,目前支持两种视图的混用,这样就需要去避免一个问题,虚拟视图画在宿主上作为”背景“,原生视图放在宿主上有可能会遮挡虚拟视图。

实时预览

安装fswatch监听文件修改,

1
2
brew install fswatch

安装qrencode生成二维码(可选),

1
2
brew install qrencode

virtualview_tools项目virtualview_tools/compiler-tools/RealtimePreview目录下,执行./run.sh启动服务器,手机和电脑连同一网络,手机运行Virtualview-Android项目(记得把HttpUtil类中的ip地址改成电脑的ip),进入模板实时预览,可以加载服务器下发的HelloWorld,点进去就可以看样式了,

接着修改文件保存,fswatch监听到修改,触发服务器重新编译HelloWorld

img

合并结果data.json如下,

1
2
3
4
5
6
7
8
9
{
"templates": [ //样式:xml -> 二进制 -> Base64.encode ,客户端拿到后decode回二进制进行解析
"QUxJVlYAAQAAOMQAAAAvAAAAkAAAAL8AAAD1AAABuAAAAAAAAAG8AAAAAAABAAAAAAABAApIZWxsb1dvcmxkAH4AAAIEqjL10AAAAABc1fDxAAAAyLCYVS4RAAAAd3CsvP////8AAAACfREwBNF35jvOOvRwYx6r5gAAAAAHBcQtOs4AAAAUXNXw8QAAAMiwmFUu////8BC4ck4AAAAkd3CsvP////8AAAACADZFLUjWynnAmy42tiGl5gAAAQEAAAAGfREwBAAdeHNpOm5vTmFtZXNwYWNlU2NoZW1hTG9jYXRpb262IaXmAG9AeyR7aXRlbXNbMF0uaW5mby50ZXh0Q29sb3J9ID8gJHtpdGVtc1sxXS5zdWJJdGVtc1swXS5pbmZvLnRleHRDb2xvcn0gOiAke2l0ZW1zWzJdLnN1Ykl0ZW1zWzBdLmluZm8udGV4dENvbG9yfX1jHqvmAClodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZc469HAACXhtbG5zOnhzadF35jsADC4uLy4uL3Z2LnhzZEjWynkAByR7dGV4dH0AAAAA"
],
"data": { //数据
"text": "Hello World!"
}
}

可见实时预览时,服务端把二进制数据进行了Base64编码(真实的业务场景也可以参考),客户端点击Refresh按钮重新加载http://127.0.0.1:7788/helloworld/data.json,在PreviewActivity中,

1
2
3
4
5
6
7
8
9
//PreviewActivity.java
//获取网络数据data.json
PreviewData previewData = new Gson().fromJson(string, PreviewData.class);
//取出templates字段
loadTemplates(previewData.templates);

//进行Base64解码,然后读取二进制数据进行解析
sViewManager.loadBinBufferSync(Base64.decode(temp, Base64.DEFAULT));

VirtualView的加持下,Tangram的动态能力得到进一步提升,实现了线上生产cell并下发替换。

一些案例

Tangram

官方show-case

img

内部Lego

img

参考文章

  • 苹果核 - 天猫客户端组件动态化的方案——VirtualView 上手体验
  • 苹果核 - VirtualView 工具大更新啦
  • 苹果核 - 提升开发体验,预览 VirtualView
  • 苹果核 - VirtualView Android 实现详解(三)—— 添加一个自定义控件
  • 文档 - Virtualview