58同城Hybrid框架的点点滴滴

Hybrid框架简介

采用Hybrid模式的原因:

  1. 纯Native的迭代太慢,不能动态更新,且不能跨平台
  2. 纯Web页,有很功能无法实现,有些动画效果实现其体验太差

整体框架结构图

WebView加载流程

  1. 在Step1里有两个作用:
    1. 可以拦截html请求,对Html请求进行白名单的判断,只有规定域名的请求才能通过
    2. 转发一些如拨打电话请求,如tel:xxx
  2. 在Step2里主要是显示Loading加载框
  3. Step3:shouldInterceptRequest()
    1. 此方法在Api为11时才有,即3.0以后才有此方法,所以在2.x系统里,无法劫持资源请求
    2. 主要用于拦截资源请求,让其走本地资源缓存,实现Native资源缓存机制
  4. Step4:onPageFinished()要等所有的资源都加载完成后,才会进行回调,但此时,界面早已经渲染出来了。
  5. Loading界面消失的机制:
    1. 在html界面渲染完后,js马上回调一个PageFinished的Action通知Native,提前消失掉Loading界面
    2. 如果没有等到PageFinished的Action,就在onPageFinished()方法里,把Loading界面消失掉

跳转协议

现在的跳转协议是一个json格式,如下所示:

1
2
3
4
5
6
7
{
"action":"loadpage",
"pagetype":"link",
"url":"http://xxxx",
"title":"标题"
"xxx":""
}

由于web页的Title是Native实现的,所以其标题需要从跳转协议里得到。

建议使用URL来做跳转协议,如下所示:

1
jump://action/pagetype?url=xxx&title=xxx

好处:外部调起时,其协议就可以统一

html拦截机制

Native实现缓存的思路是:通过shouldInterceptRequest()拦截html的请求。

js,css,image拦截机制

机制和Html的一致,都是通过shouldInterceptRequest()拦截请求。

但并不是所有的请求都会进行拦截走缓存,满足如下两种规则走缓存:

  1. 标准方式,通过在URL后面添加cachevers参数,如下所示:
    1
    http://xxx/xxx?cachevers=xx
  2. cdn的方式,URL满足cdn的格式也会走缓存,如下所示:
    1
    http://xxx/xxx_v版本号.xx

注意:整个缓存框架里,只认第一种格式,第二种cdn格式,会在shouldInterceptRequest()方法里进行转化为第一种格式,请求时,再转化为第二种格式

html,js,css,image的缓存框架

异步加载图片

虽然shouldInterceptRequest()方法是在后台线程里执行的,但如果直接在此方法里,请求图片资源,那所有的图片资源都将是同步的方式加载,影响最终的加载速度,也会阻塞shouldInterceptRequest()方法的执行,从而阻塞webview的渲染。

解决思路:创建新的线程来请求图片资源,马上返回shouldInterceptRequest()方法,但如何实现呢?通过查看WebView的源码,找到了一种方式:使用管道,代码如下:

1
2
3
4
5
6
7
8
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe(); // 创建一个管道,一个出口,一个入口
new TransferThread(context, uri, new ParcelFileDescriptor.AutoCloseOutputStream(pipe[1])).start();
AssetFileDescriptor assetFileDescriptor = new AssetFileDescriptor(pipe[0], 0, AssetFileDescriptor.UNKNOWN_LENGTH);
FileInputStream in = assetFileDescriptor.createInputStream();
return new WebResourceResponse(type, "utf-8", in);
}

缓存资源的版本号管理

缓存资源是通过其版本号来更新的,那资源的版本号应该存在哪里了?最直接的解决办法是:创建一个数据库,里面存储文件名与版本号的对应关系。我们最早也是这样实现的,这样会带来维护成本,还有其出错的概率。

最好的方案:把版本号与缓存文件存储在一起。

实现思路:不管缓存文件是文本文件,还是图片,在文件的开始位置写入一些Byte字节,这些Byte字节就存储了对应的版本号。

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
/**
* Created by maolei on 2015/9/8.
*/
public class ExtraDiskCache{

private static final String FUNCTION = "diskCache";

/** Magic number for current version of cache file format. */
private static final int CACHE_MAGIC = 0x20150908;

private static final String NO_VALUE = "null";

/** The root directory to use for the cache. */
private final File mRootDirectory;

// TODO clear file

public ExtraDiskCache(File rootDirectory){
mRootDirectory = rootDirectory;
if(!mRootDirectory.exists()){
mRootDirectory.mkdirs();
}
}

private File getFile(String fileName){
return new File(mRootDirectory, fileName);
}

public boolean save(String fileName, Map<String, String> extraInfo, InputStream in){
BufferedOutputStream fos = null;
// network inputstream need temp file;
File tempFile = getFile(fileName + "_temp");
try{
fos = new BufferedOutputStream(new FileOutputStream(tempFile));
if(extraInfo != null && extraInfo.size() > 0){
boolean success = writeHeader(fos, extraInfo);
if(!success){
throw new IOException();
}
}
byte[] buf = new byte[1024];
int len;
while ((len = in.read(buf)) > 0) {
fos.write(buf, 0, len);
}
fos.flush();
File cacheFile = getFile(fileName);
if(cacheFile.exists()){
cacheFile.delete();
}
tempFile.renameTo(cacheFile);
return true;
}catch (IOException e){
LOGGER.k(FUNCTION, "write data error", e);
}finally {
try {
if(in != null){
in.close();
}
if(fos != null){
fos.close();
}
if(tempFile.exists()){
tempFile.delete();
}
}catch (IOException e){
LOGGER.k(FUNCTION, "close stream error", e);
}
}
return false;
}

public Map<String, String> getInfo(String fileName){
BufferedInputStream bis = null;
try{
bis = new BufferedInputStream(new FileInputStream(getFile(fileName)));
return readHeader(bis);
}catch (IOException e){
LOGGER.k(FUNCTION, "getInfo error", e);
}finally {
try {
if(bis != null){
bis.close();
}
}catch (IOException e){
}
}
return null;
}


public InputStream getContentStream(String fileName){
try{
File file = getFile(fileName);
BufferedInputStream bis = new BufferedInputStream(new FileInputStream(file));
if(readHeader(bis) != null){
// current file has extra info, so return unread input stream
return bis;
}
// current file is normal file, return origin input stream
bis.close();
return new BufferedInputStream(new FileInputStream(file));
}catch (IOException e){

}
return null;
}

private Map<String, String> readHeader(InputStream in){
try {
int magic = readInt(in);
if (magic != CACHE_MAGIC) {
throw new IOException();
}
return readStringStringMap(in);
}catch (IOException e){

}
return null;
}

private boolean writeHeader(OutputStream out, Map<String, String> extraInfo){
try{
writeInt(out, CACHE_MAGIC);
writeStringStringMap(extraInfo, out);
return true;
}catch (IOException e){
return false;
}
}



/**
* Simple wrapper around {@link java.io.InputStream#read()} that throws EOFException
* instead of returning -1.
*/
private static int read(InputStream is) throws IOException {
int b = is.read();
if (b == -1) {
throw new EOFException();
}
return b;
}

static void writeInt(OutputStream os, int n) throws IOException {
os.write((n >> 0) & 0xff);
os.write((n >> 8) & 0xff);
os.write((n >> 16) & 0xff);
os.write((n >> 24) & 0xff);
}

static int readInt(InputStream is) throws IOException {
int n = 0;
n |= (read(is) << 0);
n |= (read(is) << 8);
n |= (read(is) << 16);
n |= (read(is) << 24);
return n;
}

static void writeLong(OutputStream os, long n) throws IOException {
os.write((byte)(n >>> 0));
os.write((byte)(n >>> 8));
os.write((byte)(n >>> 16));
os.write((byte)(n >>> 24));
os.write((byte)(n >>> 32));
os.write((byte)(n >>> 40));
os.write((byte)(n >>> 48));
os.write((byte)(n >>> 56));
}

static long readLong(InputStream is) throws IOException {
long n = 0;
n |= ((read(is) & 0xFFL) << 0);
n |= ((read(is) & 0xFFL) << 8);
n |= ((read(is) & 0xFFL) << 16);
n |= ((read(is) & 0xFFL) << 24);
n |= ((read(is) & 0xFFL) << 32);
n |= ((read(is) & 0xFFL) << 40);
n |= ((read(is) & 0xFFL) << 48);
n |= ((read(is) & 0xFFL) << 56);
return n;
}

static void writeString(OutputStream os, String s) throws IOException {
byte[] b = s.getBytes("UTF-8");
writeLong(os, b.length);
os.write(b, 0, b.length);
}

static String readString(InputStream is) throws IOException {
int n = (int) readLong(is);
byte[] b = streamToBytes(is, n);
return new String(b, "UTF-8");
}

static void writeStringStringMap(Map<String, String> map, OutputStream os) throws IOException {
if(map == null || map.size() == 0){
return;
}
writeInt(os, map.size());
for (Map.Entry<String, String> entry : map.entrySet()) {
writeString(os, entry.getKey());
String value = entry.getValue();
if(TextUtils.isEmpty(value)){
writeString(os, NO_VALUE);
}else{
writeString(os, entry.getValue());
}

}
}

static Map<String, String> readStringStringMap(InputStream is) throws IOException {
int size = readInt(is);
if(size <= 0){
return null;
}
Map<String, String> result = new HashMap<String, String>(size);
for (int i = 0; i < size; i++) {
String key = readString(is).intern();
String value = readString(is).intern();
if(NO_VALUE.equals(value)){
value = "";
}
result.put(key, value);
}
return result;
}

/**
* Reads the contents of an InputStream into a byte[].
* */
private static byte[] streamToBytes(InputStream in, int length) throws IOException {
byte[] bytes = new byte[length];
int count;
int pos = 0;
while (pos < length && ((count = in.read(bytes, pos, length - pos)) != -1)) {
pos += count;
}
if (pos != length) {
throw new IOException("Expected " + length + " bytes, read " + pos + " bytes");
}
return bytes;
}
}

相关的类

  1. WebResLoader:资源加载类,负责:异步加载,同步加载
  2. WebResCacheManager:资源管理类,负责:资源保存,加载,资源版本管理

交互框架

现在的交互方式有:

  1. 通过webview的addJavascriptInterface()方法交互
    优点:简单,Js可以获取返回值,从Api 1开始支持。
    缺点:不安全,js可以通过此漏洞调用用户手机里的很多功能
  2. 使用会在shouldInterceptRequest()方法交互
    优点:安全
    缺点:从Api11(即3.0)才支持,不支持js获取返回值

交互协议如下:

1
2
3
4
{
"action":"xxx",
"xxx":"xxx"
}

使用的是json协议,其中的action区分事件类型

具体的交互框架:

  1. 每一个Action协议会有对应的Bean, Parser, ActionCtrl。都是一一对应的
  2. ActionCtrl都在在具体的Fragment载体页进行注册,只有先注册过的Action,才会有相应的处理
  3. 在MessageBaseFragment里注册的Action为通用Action,所有的载体页都支持

Bean对象合法检测:在action协议解析完成后会生成一个Bean对象,所有的Bean对象都继承自ActionBean基类,在ActionBean类中新增checkWebAction()方法,以及check()抽象方法,由子类实现check()方法实现子类自己的协议检测。checkWebAction()方法执行所有ActionBean的通用检测,并在checkWebAction()方法中调用check()方法,执行子类自检。

WebView的载体页

  1. 按业务分,创建了不同的载体页,即有多个MessageBaseFragment的子类。(58当前使用的方式)
    优点:App开发载体页简单,单个载体页不会变的非常庞大,易于维护
    缺点:
    1. 载体页过多,前端人员在写跳转协议时,要区分跳转到哪个web载体页
    2. 每个载体页支持的action协议是不一样的,造成很多不兼容问题,影响了后期的扩展性
    3. 维护成本加大了
  2. 一个载体页,支持所有的Action协议,支持所有的业务。(Hybrid二期会改为此种方式)
    优点和缺点刚好和上面的方式相反,推荐使用此种方式

Cookie,Header

通过webview加载html的方式,有下面两种方法:

1
2
3
4
5
// 直接加载url
webview.loadUrl(String url)

// 在加载url时,要添加header头信息,注意:此方法在2.2时,才添加了
webview.loadUrl (String url, Map<String, String> additionalHttpHeaders)

通过上面的方法直接加载Html页面时,会自动把cookie添加,那我们带一些参数给Server的方式就有两种:

  1. 通过cookie来带数据
  2. 2.2以后,通过Header带数据

经验:

  1. 两个同时都带,cookie和header都带相同的数据
  2. 在有一些Android手机里,其cookie总是上传不成功,通过抓包发现根本没有cookie信息。(之后证实发现用户其他app也无法使用cookie)
  3. Header是完全可以保证数据不丢失的方式,但由于javascript发出的请求,都无法带上header,所以还是要使用cookie

白名单

所谓的白名单是指:不在白名单内的请求,不进行加载,或者弹出一个Dialog,提示用户。

实现思路:

  1. 本地有一个白名单列表,可以更新此列表。注意:列表里指保存域名
  2. 在WebViewClient的shouldOverrideUrlLoading()方法里,进行拦截判断。注意:判断时,要考虑一级域名,二级域名等等。

WebView添加额外功能

WebView默认情况下缺少很多功能:

  1. 不能图片上传
  2. 不能进行文件下载
  3. 不能拨打电话等等调用系统其他组件

图片上传功能:分为两种,一种通过相册选择,再上传;一种是拍照后,再上传。这两种都能支持,方法可以直接搜索就可以了。问题:有部分手机无法调启上传,机型支持问题,解决方案:通过Action,由native来做上传

文件下载:原生不支持下载的URL,把下载URL,支持转发到浏览器,进行下载。最好不要支持url支持下载。58现在不支持

调用通用组件:在shouldOverrideUrlLoading()进行通用处理,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
public boolean shouldOverrideUrlLoading(WebView view, String url) {
try {
if (url.startsWith("http:") || url.startsWith("https:") || url.startsWith("file:")) {
// Html请求
}
// 其他的通用处理
view.getContext().startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)));
return true;
} catch (Exception e) {
LOGGER.e(TAG, null, e);
}
return false;
}
感谢您的阅读,本文由 刘阳 版权所有。如若转载,请注明出处:刘阳(https://handsomeliuyang.github.io/2016/03/24/%E7%BB%8F%E9%AA%8C%E6%80%BB%E7%BB%93-58%E5%90%8C%E5%9F%8EHybrid%E6%A1%86%E6%9E%B6%E7%9A%84%E7%82%B9%E7%82%B9%E6%BB%B4%E6%BB%B4/
Android收藏的好文章
Android签名的过程