**** 原創(chuàng)不易,轉(zhuǎn)載注明出處 ****
android原生App最大的痛點就是更新周期長,稍有改動,就需要發(fā)布新版本,加上審核,最快也要3天后才能讓用戶看到新模塊。
如果能通過后臺下發(fā)數(shù)據(jù)創(chuàng)建View,執(zhí)行操作,就可以很靈活的動態(tài)控制頁面。
如果下發(fā)json,View的數(shù)據(jù)很好處理,因為android提供的控件類型是有限的,只要枚舉出來就可以。但是一個App的操作太多太多,但是不是無限多呢?也不是。
一、 只有View
假設開發(fā)一個App只需要展示基礎控件。
{
"name": "TV",
"content": "文本",
"color": "#333333",
"size": "16"
}
明顯這是一個TextView,指定了顯示的文字、顏色和大小。
{
"name": "IV",
"width": "100",
"height": "100",
"url": "https://bugly.qq.com/v2/image?id=2c06cba9-7d27-4f1c-8b0d-b932c33deaf3"
}
這個就可以生成一個ImageView。如果更細化,還可以指定margin和padding,腦補css。
同理,如果是容器布局,可以擴展一下,加一個子類集合:
{
"name": "VLL",
"children": [
{
"name": "TV"
},
{
"name": "TV"
},
{
"name": "TV"
}
]
}
"VLL"可以提前協(xié)議好是Vertical的LinearLayout,children就是子View的集合。
有了控件還需要一個頁面承載,頁面也能看成View,但是頁面需要有更多的功能,全放View會導致View的屬性過多,所以也能做一層抽象,
{
"contextName": "home",
"layout": {
}
}
"contextName"能唯一標記一個頁面,比如登錄頁面可以標記為"login","layout"實質(zhì)就是一個View,字段和上面的基礎控件一致。
有了上面的規(guī)則,現(xiàn)在可以嘗試做一個有三個文本的首頁:
{
"contextName": "home",
"layout": {
"name": "VLL",
"children": [
{
"name": "TV",
"content": "打開頁面",
"color": "#333333",
"size": "16"
},
{
"name": "TV",
"content": "彈出Toast",
"color": "#333333",
"size": "16"
},
{
"name": "TV",
"content": "請求網(wǎng)絡",
"color": "#333333",
"size": "16"
}
]
}
}
二、 View的響應
第一步已經(jīng)能自動填充控件了,但是如果真想點擊第二個TextView去彈出一個Toast,怎么處理呢?可以嘗試在View的數(shù)據(jù)里面指定一個動作:
{
"name": "TV",
"content": "彈出Toast",
"color": "#333333",
"size": "16",
"action": {
"name": "toast",
"msg": "彈出一下"
}
}
這樣點擊的時候就可以解析出一個Toast的動作。當然Action是需要提前窮舉的,還是前面說的,一個App的動作肯定不是無限的。比如跳轉(zhuǎn)一個頁面:
{
"name": "TV",
"content": "打開頁面",
"color": "#333333",
"size": "16",
"action": {
"name": "open",
"nextPage": {
"contextName": "detail",
"layout": {}
}
}
}
"nextPage"已經(jīng)能自動生成第二個頁面了。甚至于,請求也是一個Action:
{
"name": "TV",
"content": "請求網(wǎng)絡",
"color": "#333333",
"size": "16",
"action": {
"name": "request",
"url": "https://xxx.com",
"params": {
"name": "rjp",
"age": "18"
}
}
}
如果你已經(jīng)封裝了請求,上面的數(shù)據(jù)已經(jīng)夠去請求一下了,但是請求回來的數(shù)據(jù)呢?這就是說,有時候Action的動作是有后續(xù)動作的,有一種嵌套關系:
{
"name": "TV",
"content": "請求網(wǎng)絡",
"color": "#333333",
"size": "16",
"action": {
"name": "request",
"url": "https://xxx.com",
"params": {
"name": "rjp",
"age": "18"
},
"action": "setData"
}
}
"request"的后續(xù)有一個"setData"的動作。這就不好處理了,因為每個頁面的業(yè)務數(shù)據(jù)都是獨特的,數(shù)據(jù)模型無法統(tǒng)一。所以需要一個中間層,能對后臺下發(fā)的數(shù)據(jù)進行標準化輸出:
public class DataBean {
private String a;
private String b;
private String c;
private String d;
private String e;
private String f;
private String g;
}
也就是說,不管我請求哪個接口,返回的數(shù)據(jù)永遠是abcdefg,我也不關心字段究竟代表什么。
那怎么知道下發(fā)的數(shù)據(jù)應該填充到哪個控件呢?可以通過給控件設置一個value,來指定需要的數(shù)據(jù):
{
"name": "TV",
"content": "請求網(wǎng)絡",
"color": "#333333",
"size": "16",
"value": "a",
"action": {
"name": "request",
"url": "https://xxx.com",
"params": {
"name": "rjp",
"age": "18"
},
"action": "setData"
}
}
這樣點擊完請求數(shù)據(jù),如果數(shù)據(jù)里面帶上了"a": "后臺數(shù)據(jù)",就將數(shù)據(jù)填到這個TextView上。填充首先想到的就是遍歷頁面的根View,但是隨著頁面復雜化,非常耗時,可以參考局部刷新的做法,對有value屬性的View進行緩存,只要遍歷緩存集合就行了,非常高效。
三、 拼多多
說了這么多還沒有一個完整的例子,下面一步步來實現(xiàn)。
public class PageActivity extends AppCompatActivity implements IPage {
private List<View> viewCache;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_page);
FrameLayout pageContainer = findViewById(R.id.page_container);
Intent intent = getIntent();
if (intent != null && intent.hasExtra("nextPage")) {
String nextPage = intent.getStringExtra("nextPage");
PageBean pageBean = JSONObject.parseObject(nextPage, PageBean.class);
if (pageBean != null) {
viewCache = new ArrayList<>();
pageContainer.addView(LayoutFactory.createView(this, pageBean.getLayout()));
}
}
}
@Override
public Context getContext() {
return this;
}
@Override
public List<View> getViewCache() {
return viewCache;
}
}
創(chuàng)建一個簡單的頁面容器Activity,布局只有一個FrameLayout,從上一個頁面接收json,這個json描述整個Page,當然也可以通過接口請求獲取,測試階段直接從Assets讀取。
上面的關鍵是獲取到json轉(zhuǎn)成PageBean結(jié)構(gòu):
public class PageBean {
private String contextName;
private ViewBean layout;
public ViewBean getLayout() {
return layout;
}
public void setLayout(ViewBean layout) {
this.layout = layout;
}
public String getContextName() {
return contextName;
}
public void setContextName(String contextName) {
this.contextName = contextName;
}
}
ViewBean:
public class ViewBean {
private String id;
private String name;
private String content;
private String color;
private String value;
private float size = 14.0f;
private int width;
private int height;
private List<ViewBean> children;
private String action;
private String url;
private String itemType;
}
ViewBean存在一個問題就是所有的屬性都糅合在一個數(shù)據(jù)結(jié)構(gòu)里,會造成浪費,解決辦法是一個類型的View給一個Bean,然后設置ViewType,但是那是優(yōu)化時考慮的問題,目前只使用一個。
拿到了Layout就可以通過簡單工廠模式開始渲染布局了:
public class LayoutFactory {
public static View createView(IPage page, ViewBean viewBean) {
String name = viewBean.getName();
switch (name) {
case ViewType.VLL:
return createLinearLayout(page, viewBean, true);
case ViewType.HLL:
return createLinearLayout(page, viewBean, false);
case ViewType.TV:
return createTextView(page, viewBean);
case ViewType.IV:
return createImageView(page, viewBean);
default:
return new View(page.getContext());
}
}
private static View createLinearLayout(IPage page, ViewBean viewBean, boolean isVertical) {
LinearLayout vll = new LinearLayout(page.getContext());
ViewGroup.LayoutParams layoutParams = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
vll.setLayoutParams(layoutParams);
vll.setOrientation(isVertical ? LinearLayout.VERTICAL : LinearLayout.HORIZONTAL);
List<ViewBean> children = viewBean.getChildren();
if (children != null && children.size() > 0) {
int size = children.size();
for (int i = 0; i < size; i++) {
vll.addView(createView(page, children.get(i)));
}
}
return vll;
}
private static View createImageView(IPage page, ViewBean viewBean) {
ImageView imageView = new ImageView(page.getContext());
imageView.setTag(R.id.image_tag_id, viewBean);
ViewGroup.LayoutParams layoutParams = new ViewGroup.LayoutParams(viewBean.getWidth(), viewBean.getHeight());
imageView.setLayoutParams(layoutParams);
String url = viewBean.getUrl();
if (!TextUtils.isEmpty(url)) {
Glide.with(page.getContext()).load(url).into(imageView);
}
if (!TextUtils.isEmpty(viewBean.getValue())) {
List<View> viewCache = page.getViewCache();
if (viewCache != null) {
viewCache.add(imageView);
}
}
return imageView;
}
/**
* 創(chuàng)建一個TextView
*
* @param page
* @param viewBean
* @return
*/
private static View createTextView(IPage page, ViewBean viewBean) {
TextView tv = new TextView(page.getContext());
try {//多個try保證某一個臟數(shù)據(jù)不會導致view整體加載失敗
tv.setTextColor(Color.parseColor(viewBean.getColor()));
} catch (Exception e) {
e.printStackTrace();
tv.setTextColor(Color.parseColor("#333333"));
}
try {
tv.setTextSize(TypedValue.COMPLEX_UNIT_DIP, viewBean.getSize());
} catch (Exception e) {
e.printStackTrace();
tv.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14.0f);
}
if (!TextUtils.isEmpty(viewBean.getValue())) {
List<View> viewCache = page.getViewCache();
if (viewCache != null) {
viewCache.add(tv);
}
}
tv.setText(viewBean.getContent());
tv.setTag(viewBean);
tv.setOnClickListener(v -> {
ViewBean bean = (ViewBean) v.getTag();
IAction action = ActionFactory.createAction(page, bean.getAction());
action.action(bean.getAction());
});
return tv;
}
}
注意填充的過程中判斷value是否存在,存在直接存到viewCache集合里,后面設置數(shù)據(jù)能用上。
createView的方法傳入了一個IPage接口,這個接口是為了方便獲取context上下文和viewCache集合:
public interface IPage {
Context getContext();
List<View> getViewCache();
}
createTextView方法下設置了點擊事件的監(jiān)聽,當點擊的時候會觸發(fā)Action,定義了IAction接口:
public interface IAction {
void action(ActionBean action);
}
在點擊的時候拿到action數(shù)據(jù),通過ActionFactory簡單工廠生成對應的Action實體,先看一個簡單的Toast怎么實現(xiàn):
public class ToastAction implements IAction {
private IPage page;
public ToastAction(IPage page) {
this.page = page;
}
@Override
public void action(ActionBean action) {
Toast.makeText(page.getContext(), action.getMsg(), Toast.LENGTH_SHORT).show();
}
}
這樣整體App的Toast就都可以通過指定name = "toast"完成了。復雜一點的,比如請求Action之后,設置數(shù)據(jù)Action:
public class RequestAction implements IAction {
private IPage page;
public RequestAction(IPage page) {
this.page = page;
}
@Override
public void action(ActionBean action) {
if (action != null) {
try {
Thread.sleep(2_000);
} catch (Exception e) {
e.printStackTrace();
}
String response = "{\n" +
" \"a\": \"這是通過請求獲取的數(shù)據(jù),真的!\"\n" +
" }";
String lastAction = action.getAction();
ActionBean lastActionBean = JSONObject.parseObject(lastAction, ActionBean.class);
lastActionBean.setResponse(response);
ActionFactory.createAction(page, lastAction).action(lastActionBean);
}
}
}
因為請求還要接入請求框架,直接模擬請求返回的數(shù)據(jù)了。執(zhí)行設置數(shù)據(jù)Action的時候攜帶上請求回來的response:
public class SetDataAction implements IAction {
private IPage page;
public SetDataAction(IPage page) {
this.page = page;
}
@Override
public void action(ActionBean action) {
if (action != null) {
String response = action.getResponse();
if (!TextUtils.isEmpty(response)) {
DataBean dataBean = JSONObject.parseObject(response, DataBean.class);
List<View> viewCache = page.getViewCache();
if (viewCache != null && viewCache.size() > 0) {
int size = viewCache.size();
for (int i = 0; i < size; i++) {
View view = viewCache.get(i);
bindViewData(view, dataBean);
}
}
}
}
}
/**
* 綁定頁面數(shù)據(jù)
*
* @param view
* @param dataBean
*/
private void bindViewData(View view, DataBean dataBean) {
if (view instanceof TextView) {
TextView textView = (TextView) view;
ViewBean viewBean = (ViewBean) textView.getTag();
String keyData = dataBean.getData(viewBean.getValue());
textView.setText(keyData);
} else if (view instanceof ImageView) {
//TODO
}
}
}
不需要反復遍歷根節(jié)點,就能獲取到設置了value = "a"的TextView,再拿到DataBean的a值,綁定上就可以看到結(jié)果了。
復雜的ListView綁定也是可以實現(xiàn)的。