Flutter坑之共享FlutterEngine页面切换无法点击

背景

最近在研究做Flutter一块相关的内容,方案上采用的是单FlutterEngine,全局Flutter元素共用一个FlutterEngine,对于使用单Engine遇到一个很大的坑,页面切换之后无法点击,页面就像卡死了一样,以下三种情况都会发生:

第一种:两个FlutterFragment在tab中进行切换,假如AB代表两个FlutterFragment,当A切换到B,再从B切换到A的时候,页面就无法点击。

第二种:在FlutterActivity中打开新的FlutterActivity,新的FlutterActivity页面跟上述的情况一样,也是无法点击。

第三种:在Tab中打开FlutterFragment之后再打开FlutterActivity,情况一样,依然无法点击。

如下动图所示:

错误情况

(来自issuehttps://github.com/flutter/flutter/issues/49950)

但他们有一个共同的特点:当页面卡死的时候,手动进入后台(打开任务管理或者home键退出)再回到前台,页面就会“刷新”,一切又变得正常了。那么推测:多半与跟页面Fragment和Activity的生命周期有关。我尝试了在进入页面后,再手动模拟“页面退出再回来的周期”,也就是先调用一次onPause()再调用一次onResume(),然而并没有什么卵用……

回到后台再回来

可这是为什么呢??太坑了……踏遍了千山万水也没有找到有人有解决方案,很多人都说别用共享引擎,但是想到 闲鱼Flutter_boost 和HelloBike的thrio框架也都是用的共享引擎啊,他们为什么没有问题?找了很久的解决方法,两天,甚至晚上做梦都梦在关于这个问题。真的是……难受。加了各种各样的群,也没有人能解答这个问题。终于……我去打印了两个Activity的生命周期,才发现事情的端倪。

关于Activity与Fragment的切换的生命周期

这里向大家在简单介绍一下Activity和Fragment切换生命周期,相信大家都有被面试问过:现在有两个Activity A和B,在A打开B这一段时间Activity的生命周期变化情况:

A.onPause() ->B.onCreate()-> B.onStart() ->B.onResume() ->A.onStop()

A的onStop() 的调用情况分为两种:当设置Activity A的主题windowIsTranslucent属性为true,A Activity并不会调用onStop方法,只会调用onPause()方法。

ok说完了Activity再说说Fragment,对于我们的问题:Fragment生命周期考虑tab间切换(也就是两个FlutterFragment之间的切换),两个Fragment的切换,并不会导致Fragment的onPause()和onStop()调用,只会调用onHiddenChanged(boolean hidden),hiden为true表示该Fragment被隐藏了,false表示当前Fragment可见。

关于Flutter单Engine方案

现在关于讲解Flutter单Engine方案也比较多,推荐去看这篇–> 为追求高性能,我必须告诉你Flutter引擎线程的事实… 关于对单Engine的讲解,单Engine方案,我们可以简单理解为:所有应用中的FlutterView都是由同一个FlutterEngine来渲染的。当然这看起来是废话,但这就是问题的关键,那么对于所有的FlutterView都是用的同一个FlutterEngine渲染,那么FlutterEngine是怎么去控制的呢?如果让你去设计,你会怎么设计呢?

这篇博客–>flutter单引擎方案讲解了一种单Engine的实现方案,可以参考,不过其中也需要对Engine进行多次new,不过这并不是最重要的,我们需要明白的是:当FlutterEngine去渲染FlutterView B的时候,它需要attach再在B上,从FlutterView A detach掉,再返回FlutterView A的时候,它需要从FlutterView B上detach掉,再attach到A上。

Flutter坑!

现在我们明白了生命周期的变化,我们接下来去看FlutterActivity中响应生命周期中的源码。对于FlutterActivity A打开FlutterActivity B,他们依次会调用:

A.onPause() ->B.onCreate()-> B.onStart() ->B.onResume() ->A.onStop()

我们来看看FlutterActivity在对应的生命周期里面做了什么,

对于A.onPause()

1
2
3
4
5
6
@Override
protected void onPause() {
super.onPause();
delegate.onPause();
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE);
}

我们知道:FlutterActivity和Fragment主要由FlutterActivityAndFragmentDelegate来进行管理,这里我们主要关注delegate里面的内容

1
2
3
4
5
void onPause() {
Log.v(TAG, "onPause()");
ensureAlive();
flutterEngine.getLifecycleChannel().appIsInactive();
}

这里是关键我们看到了flutterEngine与生命周期相关的代码,接下来我们看其他几个生命周期里面对于flutterEngine的Lifecycle管理情况

对于B.onCreate()只进行了相关的view创建工作.

对于B.onStart()中有执行doInitialFlutterViewRun();,其中比较关键的一句就是:

1
2
3
if (host.getInitialRoute() != null) {
flutterEngine.getNavigationChannel().setInitialRoute(host.getInitialRoute());
}

对于B.onResume()

1
2
3
4
5
void onResume() {
Log.v(TAG, "onResume()");
ensureAlive();
flutterEngine.getLifecycleChannel().appIsResumed();
}

最后是A.onStop()

1
2
3
4
5
void onStop() {
Log.v(TAG, "onStop()");
ensureAlive();
flutterEngine.getLifecycleChannel().appIsPaused();
}

看到了A.onStop(),聪明的人应该都看出来问题了,我们重新整理一下从Activity A启动到B,flutterEngine相关的生命周期主要执行了以下流程:

1
2
3
4
5
6
7
8
9
A.onPause() -> flutterEngine.getLifecycleChannel().appIsInactive()

B.onCreate()-> nothing.

B.onStart() -> flutterEngine.getNavigationChannel().setInitialRoute(host.getInitialRoute());

B.onResume() -> flutterEngine.getLifecycleChannel().appIsResumed();

A.onStop()-> flutterEngine.getLifecycleChannel().appIsPaused();

其实问题已经出来了:由于我们使用的是单FlutterEngine方案,那么上面生命周期中的flutterEngine为同一实例!,由于Activity的生命周期机制,前一个Activity的生命周期的onStop是在最后调用的,也就是这时候告诉了FlutterEngine: 这时候appIsPaused,你不用在渲染了,那么这时页面就会成一种“卡死”的状态!正常的生命周期这时候FlutterEngine应该是appIsResumed()。这也就能解释为什么退出到后台(调用了onPause())再回来(调用onResume())最终的FlutterEngine是调用了.appIsResumed();显示正常。

于是找到问题了,那么如何解决呢?这还不简单,当然是去绕过不用去调用A.onStop()呀!怎么可能不用调用A.onStop() 呢?错了,不用去调用其中的delegate中的flutterEngine.getLifecycleChannel().appIsPaused();就好了,我这边的方案与Flutter_boost的方案一样,也对FlutterActivty的代码进行了重写,所以能比较灵活的去改动FlutterActivityAndFragmentDelegate。

1
2
3
4
5
void onStop() {
Log.v(TAG, "onStop()");
ensureAlive();
// flutterEngine.getLifecycleChannel().appIsPaused();
}

对于Fragment的切换也是同样一个思路,就留着大家想一下吧。

总结

1、Activity A切换到B的生命周期(A不透明的情况下):A.onPause() ->B.onCreate()-> B.onStart() ->B.onResume() ->A.onStop()

2、至于flutterEngine.getLifecycleChannel().appIsPaused();内部具体做了什么事,还得具体去研究一下,字面上理解就是。

3、Flutter混合原生做开发坑实在是太多了,官方也没有做相应的解决方案,有什么问题,一定要大胆的想,大胆的去尝试!

从Android返回键退出和直接杀死进程退出说起

最近开发的时候,使用了一个单例模式,当我返回键退出App,再重新启动,发现App的确是从首页启动,但还没有执行为单例类设置数值的位置。可是!断点调试的时候发现这时候已经有了一个数据,并且是上一次运行留下的数据,当时觉得很神奇,明明根Activity已经执行了OnDestroy(),而且再启动的确是从首页过来的,讲道理应该是“everything will be new”,但是单例里面的数据仍然存在,这可是为什么呢?

Google搜了一下,噢!恍然大悟,看到这一块的知识很久没有用就忘掉了,或者说对运行机制相关还不太熟悉吧,所以在此重新整理总结一份。

返回键退出和直接杀死进程退出的区别?

直接杀死退出:所有的内存都会被回收,重新启动应用程序时,会重新调用Application的OnCreate()方法,会调用onSaveInstanceState方法。

返回键退出程序:退出程序后,一些加载过的静态变量并没有被回收,重新启动也不需要调用Application的OnCreate()方法。

于是我们就知道,静态变量并没有被回收,而我们的单例模式实例就是静态变量,没有被回收,于是我们就知道为什么单例模式数据还存在了,于是在响应的位置对其数据进行释放。可是 why?这两者的差异究竟是什么导致的?我们要知其然,也要知其所以然。

关于直接杀死进程

这里我们应该很好去理解,Android中的每一个App都是运行在自己VM实例之中(沙盒)。每一个VM实例在linux中又是一个单独的进程,通过任务管理杀掉一个进程,那么对应进程里面的数据全部被回收掉。

关于返回键退出

通过对源码的追溯,如果不对onBackPressed()做特殊的处理,无论是AppCompatActivity还是android.app.Activity,发现都会通过执行onBackPressed(),最后到Activity的finish()方法,也就是说当App退出到根的时候,最终只是执行的是当前App根Activity的finish()方法,整个App“依然在运行”,只是看不到界面了,那么也就是说,如果在App中运行的Service之类的后台任务并没结束,仍然在运行。

那为什么单例模里面的静态变量没有回收呢?如果问你的话,你怎么答?emmmmm……因为……它没有被销毁嘛,所以它还在。当然不能这么回答了,需要用理论依据来解释。

关于方法区与静态变量

我们知道静态变量存在与JVM的方法区中,静态变量在类被加载的时候分配内存,Java虚拟机规范中说过可以不要求虚拟机在方法区实现垃圾收集,如下图所示:

image-20200908230104508

那么我们是不是可以理解为方法区中不会进行垃圾回收?查到来自《深入理解Java虚拟机》中的解释:

很多人以为方法区(或者HotSopt 虚拟机中的永久代)是没有垃圾收集的,Java虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而且性价比一般较低,在对的新生代生一般能回收70%~95%的空间,而永久代远低于此。

永久代的垃圾手机主要回收两部分内容:废弃常量无用的类。 回收废弃常量与回收Java堆中的对象非常相似。以常量池中字面量的回收为例,若字符串“abc”已经进入常量池中,但当前系统没有任何String对象引用常量池中的“abc”常量,也没有其他地方引用该字面量,若发生内存回收,且必要的话,该“abc”就会被系统清理出常量池。常量池中其他的类(接口)、方法、字段的符号引用与此类似。

无用的类需要满足3个条件:

(1)该类所有的实例都已经被回收,即Java堆中不存在该类的任何实例;
(2)加载该类的ClassLoader已经被回收;
(3)该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

那么对于我们的静态变量来说,如果不是我们手动处理的话设置实例为null的话,或其他操作的话,那么就不会满足上面的条件。那么静态变量会在什么时候被销毁呢?答案很简单了就:静态变量在类被卸载的时候销毁,类在什么时候被卸载?在进程结束的时候。那么这也自然能解释我最开始遇到的情况了,返回键返回结束App后进程并没有结束,当下一次再启动App的时候,进程并没有销毁而,因是同一个进程,所以单例中的数据依然存在。

基于AndroidVideoCache的预加载

〇、背景

最近有做需求关于视频缓存,了解到相关的开源库AndroidVideoCache,一款市面上相对比较流行的视频缓存框架,而我想利用该框架进行视频缓存的处理,并且希望能够支持预加载。然而该框架作者在18年就已经停止了维护,所以留下了无限的编程空间给其他程序员,对于视频预加载,只搜到一篇《AndroidVideoCache源码详解以及改造系列-源码篇》,然而点进该作者的博客列表,说好的预加载呢???后面也没有了下文,搜遍全网好像没有做AndroidVideoCache的预加载相关的事情,那么这样子的话……自己干吧。

首先需要明白AndroidVideoCache的实现原理,推荐查看《AndroidVideoCache-视频边播放边缓存的代理策略》这里不再赘述。

其实预加载的思路很简单,在进行一个播放视频后,再返回接下来需要预加载的视频url,启用后台线程去请求下载数据,不过中间涉及的细节逻辑比较多。

一、实现方案

主要逻辑为:

1、后台开启一个线程去请求并预加载一部分的数据

2、可能需要预加载的数据大于>1,利用队列先进入的先进行加载,加上前面的条件 使用HandlerThread再适合不过了。

我们首先定义好需要去处理的任务情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private void preload( String method,Call call) {
switch (method) {
case "addPreloadURL":
addPreloadURL(call); //添加url到预加载队列
break;
case "cancelPreloadURLIfNeeded":
cancelPreloadURLIfNeeded(call); //取消对应的url预加载(因为可能是立马需要播放这个视频,那么就不需要预加载了)
break;
case "cancelAnyPreloads":
cancelAnyPreLoads();//取消所有的预加载,主要是方便管理任务
break;
default:

}
}

那么对于每次的预加载逻辑基本上是这样的方法执行顺序:

cancelPreloadURLIfNeeded()->addPreloadURL(); //取消对应url加载的任务,因为有可能该url不需要再进行预加载了(参考抖音,当用户瞬间下滑几个视频,那么很多视频就需要跳过了不需要再进行预加载)

cancelAnyPreLoads()->addPreloadURL(); //取消对应url加载的任务(这时候需要立马播放最新的视频,那么就应该让出网速给该视频),之后再添加新一轮的预加载url。

接下来具体的处理逻辑VideoPreLoader类,我直接放上所有的代码逻辑吧,为方便观察删除了一部分不太重要的逻辑,其实总体流程也比较简单。

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
public class VideoPreLoader {
private Handler handler;
private HandlerThread handlerThread;
private List<String> cancelList = new ArrayList<>();

private VideoPreLoader() {
handlerThread = new HandlerThread("VideoPreLoaderThread");
handlerThread.start();
handler = new Handler(handlerThread.getLooper()) {
@Override
public void handleMessage(Message msg) {
super.handleMessage(msg);
}
};
}

void addPreloadURL(final VideoPreLoadModel data) {
handler.post(new Runnable() {
@Override
public void run() {
realPreload(data);
}
});
}

void cancelPreloadURLIfNeeded(String url) {
cancelList.add(url);
}

void cancelAnyPreLoads() {
handler.removeCallbacksAndMessages(null);
cancelList.clear();
}

private void realPreload(VideoPreLoadModel data) {
if (data == null || isCancel(data.originalUrl)) {
return;
}
HttpURLConnection conn = null;
try {
URL myURL = new URL(data.proxyUrl);
conn = (HttpURLConnection) myURL.openConnection();
conn.connect();
InputStream is = conn.getInputStream();
byte[] buf = new byte[1024];
int downLoadedSize = 0;
do {
int numRead = is.read(buf);
downLoadedSize += numRead;
if (downLoadedSize >= data.preLoadBytes || numRead == -1) { //Reached preload range or end of Input stream.
break;
}
} while (true);
is.close();
}
....
}

private boolean isCancel(String url) {
if (TextUtils.isEmpty(url)) {
return true;
}
for (String cancelUrl : cancelList) {
if (cancelUrl.equals(url)) {
return true;
}
}
return false;
}
}

对于这段代码中其实有“两个”队列,一个是HandlerThread中的队列,熟悉消息机制的同学应该都能明白,内部是一个looper在不断地循环获取消息,当一个消息处理完毕之后才会处理下一个消息。我还定义了一个就是取消队列,因为HandlerThread中的任务我们不太好控制取消具体的任务,所以设置了一个取消队列,当之后的消息再需要执行的时候会首先判断是否是在取消队列里面,这样子就能做到对预加载队列逻辑的控制。

二、关于一些细节问题

这样子我们在播放一个视频的时候,只需要传给我们接下来将会播放的视频的URL,我们就能对其预加载并缓存下来,但是会存在其他条件:

预加载的长度?

对于视频加载长度,我们很容易想到在视频url请求加入Range在header上面,比如

1
conn.addRequestProperty("Range", "0-102400");

我们只获取前102400 bytes,不用将整个视频全部进行预加载,我有进行这样的尝试,但是实际发现是有坑的。我做了很多尝试,发现不论怎么请求,拿到的 responseCode 虽然是206,但是 还是把数据给全部下载完了,这就有点不科学了!!

最终去源码中才发现:源码有对range做正则匹配

1
2
3
4
5
6
7
8
9
10
private static final Pattern RANGE_HEADER_PATTERN = Pattern.compile("[R,r]ange:[ ]?bytes=(\\d*)-");

private long findRangeOffset(String request) {
Matcher matcher = RANGE_HEADER_PATTERN.matcher(request);
if (matcher.find()) {
String rangeValue = matcher.group(1);
return Long.parseLong(rangeValue);
}
return -1;
}

看清楚了 “[R,r]ange:[ ]?bytes=(\d)-“* 它只去匹配了前面的的,也就是说 我传入了 0-102400 它最终只当作是:Range:0- 来处理,导致addRequestProperty设置的range实现。坑!不过能理解作者为什么这么做,后面总结会讲到。没有办法只有使用最原始的方法进行判断了:在每次获取inputStream的时候进行判断是否达到预加载的大小,虽然有一定的性能开销,但是不去改源码的话也没有 办法了。

1
2
3
4
5
6
7
8
do {
int numRead = is.read(buf);
downLoadedSize += numRead;
if (downLoadedSize >= data.preLoadBytes || numRead == -1) { //Reached preload range or end of Input stream.
break;
}
} while (true);
is.close();

三、总结

本文主要讲了基于AndroidVideoCache的预加载具体实现原理,以及其中遇到的坑

1、预加载主要通过HandlerThread去实现后台网络的访问以及缓存的处理逻辑

2、加入取消队列去控制对应需要取消的任务

3、对于预加载的size只能通过读取的时候进行判断,没有办法使用range去判断。其实很容易理解作者为什么正则要这样写,因为它只是一个视频缓存框架,主要是用来做“边播边存”,所以每次去进行请求的时候应该都是在原有的缓存之上去进行缓存数据处理,而缓存最终需要处理完的就是 content-size,不需要再去管Range中的结束范围了。

WeakHashMap与Java引用相关

记得在很久之前有写过一篇《Java中的Reference解析》,主要讲的是Java中的四种引用方式与引用队列,不过这些都是基础的理论知识,最近开发项目中有使用到WeakHashMap,对于Java的引用以及引用队列有了更深的了解,在此做个相关总结。

一、WeakHashMap的实现方式

总体来说,WeakHashMap的底层数据结构与HashMap的实现差不多,都是用“拉链法”来实现,主要区别在于WeakHashMap的Entry 继承于WeakReference,并维护一个ReferenceQueue,使其具有了“弱引用的特性”,其构造方法可以看出:

1
2
3
4
Entry(Object key, V value,ReferenceQueue<Object> queue,int hash, Entry<K,V> next) {
super(key, queue); //这里比较关键
……
}

其中的super父类的代码实现为:

1
2
3
public WeakReference(T referent, ReferenceQueue<? super T> q) {
super(referent, q);
}

通过对父类的构造方法可以知道,WeakMap的key值为弱引用类型,回顾一下弱引用的特点:垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。 于是WeakHashMap的作用就凸显出来了:对于数据中Key-value,key因为使用的弱引用会被回收,而value也会得到对应的释放。以我这次的需求为例:key为Webview页面或者Flutter/RN页面,value为调用native方法相关存储的对象。当页面需要关闭调webivew的时候,Webview应该要被释放,要不然会产生内存泄漏,当其被释放之后,对应的value也没有意义了,所以也需要被释放掉。

那么WeakHashMap是如何让value释放的呢?

二、WeakHashMap如何释放无用的Value

要回收无用的Value,那么引用队列(ReferenceQueue)就派上用场了,回顾一下引用队列的作用:当一个引用(软引用、弱引用)关联到了一个引用队列后,当这个引用所引用的对象要被垃圾回收时,就会将它加入到所关联的引用队列中。
所以判断一个引用对象是否已经被回收的一个现象就是,这个对象的引用是否被加入到了它所关联的引用队列。
那么对于WeakHashMap也是利用这一点特性,在其代码中put\get等方法都有执行对应等检查

1
2
3
4
5
public V put(K key, V value) {
Object k = maskNull(key);
int h = hash(k);
Entry<K,V>[] tab = getTable(); //具体实现在getTable执行的expungeStaleEntries里面
……
1
2
3
4
5
public V get(Object key) {
Object k = maskNull(key);
int h = hash(k);
Entry<K,V>[] tab = getTable();
……
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
/**
* Expunges stale entries from the table.
*/
private void expungeStaleEntries() {
for (Object x; (x = queue.poll()) != null; ) { //这里的queue就是引用队列
synchronized (queue) {
@SuppressWarnings("unchecked")
Entry<K,V> e = (Entry<K,V>) x;
int i = indexFor(e.hash, table.length);

Entry<K,V> prev = table[i];
Entry<K,V> p = prev;
while (p != null) {
Entry<K,V> next = p.next;
if (p == e) {
if (prev == e)
table[i] = next;
else
prev.next = next;
// Must not null out e.next;
// stale entries may be in use by a HashIterator
e.value = null; // Help GC
size--;
break;
}
prev = p;
p = next;
}
}
}
}

从上面的代码逻辑可以很清楚的知道:WeakHashMap通过对引用队列的数据进行检查,对key被回收对象的对应Value进行了回收。

三、总结

1、WeakHashMap的Entry 继承于WeakReference,并维护一个ReferenceQueue
2、在执行get\put等相关数据操作的时候 会对数据进行相关处理,主要是清除掉无用对象对

Http缓存机制

最近有接触到Http缓存机制的问题,自己进行一个总结。

一、Http缓存总体流程

浏览器加载一个页面的缓存流程如下:

  1. 浏览器先根据Http Header信息来判断是否命中强缓存。如果命中则直接加载本地缓存中的资源,并不会将请求发送到服务器。
  2. 如果未命中强缓存,则浏览器会将资源加载请求发送到服务器。服务器来判断浏览器本地缓存是否失效。若可以使用,则服务器并不会返回资源信息,浏览器继续从缓存加载资源,虽然与强缓存加载的是“同一份缓存”,但是由于流程与性质不一样,我们把它叫做协商缓存
  3. 如果未命中协商缓存,则服务器会将完整的资源返回给浏览器,浏览器加载新资源,并更新缓存。

强缓存:不会向服务器发送请求,直接从本地缓存中读取资源返回200的状态码。

from memory cache一般脚本、字体、图片会存在内存当中

from disk cache一般非脚本会存在磁盘当中,如css等

协商缓存:向服务器发送请求,服务器根据请求中的Header的字段判断是否命中协商缓存,如果命中,则返回304状态码并带上新的响应头通知浏览器从缓存中读取资源

与之相关的字段为:
强缓存:cache-control、expires
协商缓存:Last-Modified/if-Modified-Since、Etag/if-None-Match.

其实整个缓存机制也就是围绕着这几个字段所展开

二、强缓存流程

强缓存是由Http的Response Header中的Expires或者Cache-Control两个字段来控制的,用来表示资源的缓存时间。如果Cache-control与expires同时存在的话,Cache-control的优先级高于expires。

Expires

是一个http1.0提出的概念,它描述的是一个绝对时间,由服务端返回

expires: Mon, 11 Jun 2029 08:34:12 GMT

Cache-Control

Catche-control是http1.1提出的概念,优先级高于expires,描述的是一个相对时间

cache-control: max-age=315360000

除了max-age外,cache-control还有其他几个参数:
-no-cache:不使用本地缓存。需要使用缓存协商,先与服务器确认返回的响应是否被更改,如果之前的响应中存在ETag,那么请求的时候会与服务端验证,如果资源未被更改,则可以避免重新下载。
-no-store:直接禁止浏览器缓存数据,每次用户请求该资源,都会向服务器发送一个请求,每次都会下载完整的资源。
-public:可以被所有的用户缓存,包括终端用户和CDN等中间代理服务器。
-private:只能被终端用户的浏览器缓存,不允许CDN等中继缓存服务器对其缓存。

如过Cache-Control和Expires条件都不满足,也就是说:像cache-control的字段为-no-cache和-no-store以及max-age不满足条件或者当前时间大于Expires的时间的时候,那么强缓存是没有被命中的,接下来要继续进行协商缓存的流程。

三、协商缓存流程

协商缓存相对于强缓存流程就复杂一点了,主要通过:Last-Modified/If-Modified-SinceETag/If-None-Match来控制。Etag是服务器自动生成或者由开发者生成的对应资源在服务器端的唯一标识符,能够更加准确的控制缓存。Last-Modified与ETag是可以一起使用的,服务器会优先验证ETag,一致的情况下,才会继续比对Last-Modified,最后才决定是否返回304

Last-Modified/If-Modified-Since

Last-Modified 表示本地文件最后修改日期,浏览器会在Request Header加上If-Modified-Since(上次返回的Last-Modified的值),询问服务器在该日期后资源是否有更新,有更新的话就会将新的资源发送回来。

但是单纯使用Last-Modified 会有以下问题:

  1. Last-Modified标注的最后修改只能精确到秒级,如果某些文件在1秒钟以内,被修改多次的话,它将不能准确标注文件的修改时间

  2. 如果某些文件会被定期生成,当有时内容并没有任何变化,但Last-Modified却改变了,导致文件没法使用缓存

  3. 有可能存在服务器没有准确获取文件修改时间,或者与代理服务器时间不一致等情形

所以在HTTP/1.1的时候加入了ETag/If-None-Match来解决这些问题,因而ETag的优先级高于Last-Modified。

ETag/If-None-Match

ETag可以保证每一个资源是唯一的,资源变化都会导致ETag变化。ETag值的变更则说明资源状态已经被修改。服务器根据浏览器上发送的If-None-Match值来判断是否命中缓存。

If-None-Match的header会将上次返回的Etag发送给服务器,询问该资源的Etag是否有更新,有变动就会发送新的资源回来.

Etag是服务器自动生成或者由开发者生成的对应资源在服务器端的唯一标识符,能够更加准确的控制缓存。Last-Modified与ETag是可以一起使用的,服务器会优先验证ETag,一致的情况下,才会继续比对Last-Modified,最后才决定是否返回304。

四、总结

一、由于HTTP一直在发展,所以对于HTTP的缓存字段也变得越来越复杂,其实我们很清晰的可以知道Expires与Last-Modified/If-Modified-Since是Http/1.0时代的产物。 Cache-Control与ETag/If-None-Match是HTTP/1.1为解决HTTP/1.0新增出来的字段,这样对比去记忆理解起来,其实缓存机制也就变得很好理解了。

二、对于第一次请求(肯定是没有任何缓存的),那么直接向服务器请求资源并将下载好的资源进行缓存,为下一次请求做缓存准备。

三、对于第二次之后的请求,那么本地是有缓存的,那么先通过cache-control的规则判断(对于Http1.0还是Expires)来判断本地缓存是否过期,如果没过期,那么直接使用。如果过期了,就再判断Etag(具体流程可以参考:Etag与HTTP缓存机制),通过发送If-None-Match(也就是上次存入的Tag的值),服务器进行一个决策判断返回200还是304。之前有说到,Last-Modified与ETag是可以一起使用的,服务器会优先验证ETag,也就是Etag不存在或者其他情况那么会使用Last-Modified来进行判断,通过向服务器发送If-Modified-Since,然后服务器进行一次决策。

看流程图可能一下子就明白了:

图片来自https://images2018.cnblogs.com/blog/940884/201804/940884-20180423141951735-912699213.png

参考:

https://www.cnblogs.com/ranyonsue/p/8918908.html
https://www.jianshu.com/p/19c2e397e22a

想统计自己总共提交了多少行代码?

作为一名程序员,我们很想知道自己到底提交了多少行代码到远程仓库,有没有什么工具能够帮我们统计自己写过的代码行数呢?答案是有的。这是本次博文的最终效果。
在这里插入图片描述

对于代码提交行数统计,通过git 的系统命令就能做到,如下代码所示

1
2
git log --author='username' --pretty=tformat: --numstat | awk '
{add += $1; subs += $2; loc += $1 - $2 } END { printf "添加了%s,删除了%s,合计%s\n", add, subs, loc }' -

只需要在如下命令输入自己的username就行了,效果如图所示·
在这里插入图片描述

但是有的人由于环境原因,为了区分一些环境,比如办公司叫:username.office 在家的电脑上叫做: user.home 诸如此类,难道得手动一个一个统计么?当然不行了。

众所周知,由于工程项目变得更越来越大,拆库也说见不鲜,于是自己的代码分布不同的项目工程,我们想要利用git的统计命令的话就有点吃力了,需要一个一个地进入相应目录进行命令输入?当然不行了。

今天自己写了一份脚本主要用于统计分布在某个文件夹下所有的代码提交行数,git开源地址:https://github.com/VomPom/ForFun源码如下

如何使用?

0、将自己需要统计的项目文件目录整理到一个文件夹

1、讲users_name换成自己的的用户名

2、由于文件夹下可能有一些例外的不需要统计,添加该文件夹名

3、讲该shell脚本移动到某个名录下

4、最后利用 sh codeLine.sh 执行命令

在这里插入图片描述

在这里插入图片描述

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
########################################################
#
# Created by https://julis.wang on 2020/02/28
#
# Description : 统计代码提交行数
#
########################################################

#!/bin/bash

#这里添加你的git常用用户名。考虑到每个人的账号可能有很多个,所以定义成数组
users_name=("julis" "julis.wang" "julis.wang.hp")

#过滤一些不需要去遍历的文件夹
filter_path=("Backend" "test" "sdk" "fork" "ArProject")




########################################################
# 以下代码不需动
########################################################

export index=0 #记录当前的位置
export add_line_count=0 #添加的line总行数
export remove_line_count=0 #删除的总行数

export array_git_repositories=() #用于记录仓库名
export add_code=() #记录所有用户对某个库的添加的行数
export remove_code=() #记录所有用户对某个库的删除的行数

#判断是否需要过滤该目录
function is_fileter_dir() {
for i in "${!filter_path[@]}"; do
if [ $1 == "${filter_path[$i]}" ]; then
return 1
fi
done
return 0
}
#对命令执行的返回值进行数据切割
function get_add_remove_count() {
string=$1
array=(${string//,/ })
if [ ! ${array[0]} ]; then
add_line=0
else
add_line=${array[0]}
fi

if [ ! ${array[1]} ]; then
remove_line=0
else
remove_line=${array[1]}
fi

if [ ! ${add_code[$index]} ]; then
add_code[$index]=0

fi
if [ ! ${remove_code[$index]} ]; then
remove_code[$index]=0

fi
remove_code[$index]=`expr ${remove_code[$index]} + $remove_line`
add_code[$index]=`expr ${add_code[$index]} + $add_line`

echo "用户"$2"添加了="$add_line"行 删除了"$add_line"行"

}
#获取该用户在该文件夹下的提交代码数
function get_user_line() {
# output分别去接收 该文件夹下的提交以及删除行数
output=$(git log --author=${1} --pretty=tformat: --numstat | awk '
{add += $1; subs += $2; loc += $1 - $2 } END { printf "添加了%s,删除了%s,合计%s\n", add, subs, loc }' -)
get_add_remove_count $output ${1}
}

#遍历每个用户名
function trans_every_user() {
for i in "${!users_name[@]}"; do
get_user_line "${users_name[$i]}"
done
cd ..
}

# 整体流程,从文件夹出发
for path in `ls -l $(dirname $0)|awk -F " " '{print $9}'`
do
if [ -d $path ]
then
is_fileter_dir $path
if [ $? == 1 ]
then
echo "<=========过滤了【"$path"】======>"
else
echo "<=========获取【"$path"】的Git代码提交数据======>"
index=${#array_git_repositories[@]} #用于记录当前在第几个文件夹下处理
array_git_repositories=(${array_git_repositories[@]} $path)

cd $path
trans_every_user
fi
fi
done
all_add_line=0
all_remove_line=0
echo '==============================================================================='
echo " 本次共统计了【"${#array_git_repositories[@]}"】个仓库 by julis.wang "
echo '==============================================================================='
printf "%-30s %10s %10s %10s\n" "Folder" "Add" "Remove" "All"
echo '-------------------------------------------------------------------------------'
for ((i=0;i<${#array_git_repositories[@]};i++))
do
all_add_line=`expr $all_add_line + ${add_code[$i]}`
all_remove_line=`expr $all_remove_line + ${remove_code[$i]}`
printf "%-30s %10s %10s %10s\n" ${array_git_repositories[$i]} ${add_code[$i]} ${remove_code[$i]} `expr ${add_code[$i]} - ${remove_code[$i]}`
done
echo '-------------------------------------------------------------------------------'
printf "%-30s %10s %10s %10s\n" "Total" $all_add_line $all_remove_line `expr $all_add_line - $all_remove_line`
echo '==============================================================================='

写在最后:
由于本人不太擅长编写shell脚本,所有其中的代码实现方式可能比较粗糙,望理解。

从奶酪夹心饼干生产中来学习Android 中的gradle构建

最近终于有机会做一些关于Android plugin相关的东西,之前虽然有学习过《Android 权威指南》一书,但是并没有进行一个实战操作,都是一些理论相关的学习。最近做了一个plugin主要是为了提取class文件里面的注解信息然后讲起搜集并上传。在实践中回过头发现很多知识都已经遗忘,所以本文对相关一些比较核心的知识进行一个回顾与梳理。
Gradle的思维导图
内容如下:

一、Gradle概述
二、Groovy
三、Gradle的依赖
四、Gradle的Task构建与执行
五、Gradle插件

一、Gradle概述

在Android开发中,Gradle是每个开发者都会接触的,Gradle 是一个非常优秀的项目构建工具。这是大家都知道的,但是又有啥用呢?

最开始的时候很难理解gradle到底是干什么的,相关知识都比较离散,所以很多东西没有串起来,从而导致理解起来比较困难。

我自己总结就是:Gradle是一个构建工具,它存在的目的是产生一套“流水线”,对于安卓开发而言这个流水线就是从本地的编写代码以及资源整合到最终生成的产品过程。

用一个很形象的例子举例,我们现在要生产一包奶酪夹心饼干,于是我们得定义一个生产顺序:先让有的地方去生成饼干,有的地方生成出来奶酪,之后再让两块饼干夹着一块奶酪,最后再将它们装进一个小包装袋里面。

另一种情况:如果我想在奶酪中加一点果酱,那么我们不需要重新建立一套生产线,只需要在两块饼干与奶酪结合的过程中修改一下加入果酱的流程。

再另一种情况:如果我生产出来的奶酪夹心饼干不需要包装,那只需要在最后一个步骤让它另外走一条线路,毕竟没有包装的又不是不能吃,对吧?
奶酪饼干不同的生产流程
如上图所示,我们定义了三种流程,每种流程最后的产出物是不一样的,因为流程的“初始化”的东西是不一样的以及过程中的“配置”,所以“执行”的时候就不一样。

对比我们安卓开发:本地的Java文件以及资源文件就是对应的饼干以及奶酪,最终生成的面向用户的apk文件就是包装好的奶酪夹心饼干。

如果我们想打Debug包,那么就像是一个散装的饼干,我们能自己用用,但是还不能面向用户,如果想打Release包那么就是最终的产品形态能直接面向用户。

上面的例子讲得比较长,其实主要想让更多人能够更好地去理解gradle的用处。

当我们每次点击Android Studio的 run运行按钮之后,会看到控制台输出一大堆相关日志,例如下图所示:
Android Studio系统封装好的gradle Task
其实这些都是系统为我们封装好的一些task
点击 run 按钮,就相当于执行了一次 Gradle Task,一般来说,是Task assembleDebug或者Task assembleRelease

Gradle是目前Android主流的构建工具,无论通过命令行还是通过AndroidStudio来build,最终都是通过Gradle来实现的。以及Android领域的探索已经越来越深,不少技术领域如插件化、热修复、构建系统等都对Gradle有相关的需要。

二、Groovy

知道了Gradle的用处之后,我们很形象的知道Gradle是为了去产生一个流水线。那这个流水线是利用什么做到的呢?对于奶酪饼干生产的工厂他们是不同的车间机械工具直接的逻辑组装。而对于Gradle则是利用groovy语言编写出来的相关脚本从而来进行一个编译相关的配置。这里不再具体描述groovy语言的具体用法,这里我列举出来几个自己认为比较重要的几个技术点。

1、Closure(闭包)
闭包是的groovy语言具有,而Java语言不具有的特性,有人说Lambda表达式就是闭包,但是两则还是有一定的差异的,有兴趣的同学可以去看看这篇Java中Lambda表达式解析

定义闭的语意 :

{ [closureParameters -> ] statements }

其中[closureParameters->]代表参数,多参数用逗号分割,用->隔开参数与内容,没有参数可以不写->例如我们精彩在.gradle文件里面看到这样的内容:
闭包
其中projcet就是[closureParameters->]->之后的respositories就是statements,对于这段代码而言,statements里面又是一个闭包,如果改写成Java的样子就更形象了:

1
2
3
void subprojercts(Project projct) {
doSomething....
}

2、方法的输入参数优化
groovy中定义的函数,如果至少有一个参数,在调用的时候可以省略括号。比如这样

1
2
3
def func(String a){
println(a)
}
1
func 'hello'

在gradle有大量省略括号调用函数的例子,比如

1
2
3
4
defaultConfig {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.targetSdkVersion
}

比如这里minSdkVersion 和 targetSdkVersion 其实就是调用了两个函数,传入了不同的参数,在AndroidStudio里面可以点进去查看函数实现

当然如果某个函数没有参数,那就不能省略括号,否则会当成一个变量使用

3、类的Property

如果类的成员变量没有加任何权限访问,则称为Property, 否则是Field,filed和Java中的成员变量相同,但是Property的话,它是一个private field和getter setter的集合,也就是说groovy会自动生成getter setter方法,因此在类外面的代码,都是会透明的调用getter和setter方法。

4、Trait

特性使用关键字 trait 声明,可以拥有普通成员和抽象成员。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

trait MessageHandler {
// 属性
int minLenght
// 方法
// 普通方法
void echo(String msg) {
println(msg)
}
// 抽象方法
abstract void show(String msg)
}
trait AnotherMessageHandler {
// 抽象方法
abstract void show(String msg)
}

class Message implements AnotherMessageHandler, MessageHandler {
.......
}

Groovy 中特质本质上是运行时对接口的实现,所以其方法的访问控制符只支持 public 和 private。从代码的书写可以看出来trait又像java中的abstract类又像interface
说他像interface是因为从编写上看就是使用了implements关键字,但是接口又不能使用普通方法。说他像抽象类,因为其内部使用了abstract定义抽象方法。但是它又能implements多个,而达到“多继承”的特性。因此它不是接口,也不是抽象类,它是 trait

三、Gradle的依赖

我们继续回到上面奶酪夹心饼干的生产上面,在产出奶酪夹心饼干之前,我们需要分别生产好单独的饼干与奶酪。假如我们的饼干原料有很多种,姑且我们叫他饼干v1,饼干v2……饼干vn ,奶酪也有很多种,我们叫它奶酪v1,奶酪v2……奶酪vn。那这么多种具体生产起来就应该有相关的选择,在Android开发中各种库都被单独抽了出来,只需要单独声明出来需要用哪个库即可。

我们平时看的的dependencies如下所示

1
2
3
4
5
6
7
8
9
10
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'com.android.support:appcompat-v7:28.0.0'
implementation 'com.victor:lib:1.0.4'
api 'com.android.support:recyclerview-v7:28.0.0'
implementation 'com.google.code.gson:gson:2.8.6'
implementation('com.wanjian:sak:0.1.0') {
exclude group: 'com.android.support', module: 'appcompat-v7'
}
}

1、依赖配置
Gradle依赖的配置中主要使用以下关键字,摘自谷歌官方Gradle文档->添加编译依赖项

implementation
Gradle 会将依赖项添加到编译类路径,并将依赖项打包到编译输出。不过,当您的模块配置 implementation 依赖项时,会让 Gradle 了解您不希望该模块在编译时将该依赖项泄露给其他模块。也就是说,其他模块只有在运行时才能使用该依赖项。

api
Gradle 会将依赖项添加到编译类路径和编译输出。当一个模块包含 api 依赖项时,会让 Gradle 了解该模块要以传递方式将该依赖项导出到其他模块,以便这些模块在运行时和编译时都可以使用该依赖项

annotationProcessor
要添加对作为注解处理器的库的依赖关系,您必须使用 annotationProcessor 配置将其添加到注解处理器类路径。这是因为,使用此配置可以将编译类路径与注解处理器类路径分开,从而提高编译性能。如果 Gradle 在编译类路径上找到注解处理器,则会禁用避免编译功能,这样会对编译时间产生负面影响(Gradle 5.0 及更高版本会忽略在编译类路径上找到的注解处理器)。

2、依赖的传递与冲突
在Maven仓库中,构件通过POM(一种XML文件)来描述相关信息以及传递性依赖。Gradle 可以通过分析该文件获取获取所以依赖以及依赖的依赖和依赖的依赖的依赖,为了更加直观的表述,可以通过下面的输出结果了解。

1
2
3
4
5
6
7
8
9
10
11
+--- com.github.hotchemi:permissionsdispatcher:2.2.0
| \--- com.android.support:support-v4:23.1.1 -> 28.0.0
| +--- com.android.support:support-compat:28.0.0
| | +--- com.android.support:support-annotations:28.0.0
| | +--- com.android.support:collections:28.0.0
| | | \--- com.android.support:support-annotations:28.0.0
| | +--- android.arch.lifecycle:runtime:1.1.1
| | | +--- android.arch.lifecycle:common:1.1.1
| | | | \--- com.android.support:support-annotations:26.1.0 -> 28.0.0
| | | +--- android.arch.core:common:1.1.1
| | | | \--- com.android.support:support-annotations:26.1.0 -> 28.0.0

我依赖hotchemi:permissionsdispatcher这个库,而它内部又陆陆续续地依赖了后面的一大堆。借助Gradle的传递性依赖特性,你无需再你的脚本中把这些依赖都声明一遍,你只需要简单的一行,Gradle便会帮你将传递性依赖一起下载下来。

然而问题来了这里面依赖了:android.arch.core:common:1.1.1 而我本地其他地方又使用了android.arch.core:common:1.0.0老版本。那我该如何去做这件事呢?

于是便有了如下关键词:
exclude
force
transitive

具体作用如下代码所示

1
2
3
4
5
6
7
8
9
implementation ('com.google.code.gson:gson:2.8.6') {
force = true //强制使用这个版本的库
}
implementation ('de.hdodenhof:circleimageview:3.0.1') {
transitive = true //防止向外暴露
}
implementation('com.wanjian:sak:0.1.0') {
exclude group: 'com.android.support', module: 'appcompat-v7' //排除里面不需要的库
}

四、Gradle的Task构建与执行

再回到生产饼干的例子上面来,最开始我们定义了一些流程,然后再让机器以该流程去执行。

比如先准备两块饼干再与奶酪进行加工生成夹心饼干,最后再加入包装。这是一条正确的流水,我们不可能让加入包装在加工生成夹心饼干之前。于是我们得定义一些约束,让其有正确的执行顺序。或者我们像加入果酱,那么就应该对原始的流程进行一些添加。

对于Gradle中,我们以Task为单位,类比生产奶酪饼干,生成饼干是一个专门的Task,生成奶酪也是一个专门的Task,加工成夹心也是一个Task……对于Android开发,将java文件编译为class,再到最后的dex生成都是Task

在Task的构建与执行中主要分为三个流程:

初始化(Initialization)
settings.gradle确定参与构建的module
为每个module创建Project对象实例

配置(Configuration )
build.gradle脚本执行,配置对应project实例
创建有向无环图
通过finalizedBy指定后续
通过must/shouldRunAfter约束执行顺序

执行(Execution )
根据关系图执行task
监听器

主要流程如图所示(图片摘自https://www.jianshu.com/p/0acdb31eef2d):
图片来自https://www.jianshu.com/p/0acdb31eef2d

五、Gradle插件

继续奶酪夹心饼干的故事,如果夹心饼干模样规规矩矩没有花纹,岂不是很low?于是工厂专门研发了一款能让饼干产生纹路的机器,并在加工成夹心饼干之前将纹路印到饼干上面去,假如这台机器我们把它叫做“印花纹机”,是一个能从整个生产流程中独立的出来的机器,这台“印花纹机”也能用在生产其他的饼干上。

对应在我们的Android开发中,在构建流程中我们抽离出来一些功能,将其独立开来,这就是plugin,这里不再讲解plugin的编写相关操作,可以参考Gradle 自定义 plugin

1、插件分类

脚本插件
顾名思义,如下图所示我们将对应的插件脚本中加入相关插件的逻辑,如下图所示,“other.gradle”便是一个插件

1
apply from: 'other.gradle'

二进制插件
二进制插件就是实现了 org.gradle.api.Plugin 接口的插件,每个 Java Gradle 插件都有一个 plugin id,可以通过如下方式使用一个 Java 插件:

1
apply plugin : 'maven'

通过上述代码就将 Java 插件应用到我们的项目中了,其中 maven 是 Java 插件的 plugin id,对于 Gradle 自带的核心插件都有唯一的 plugin id

2、打包方式

build script
在插件分类中我们提到有apply from: 'other.gradle' 其中other.gradle就是一个打包好的build script

buildSrc
将插件写在工程根目录下的buildSrc目录下,这样可以在多个模块之间复用该插件。
buildSrc是Gradle在项目中配置自定义插件的默认目录,但它并不是标准的Android工程目录,所以使用这种方式需要我们事先手动创建一个buildSrc目录
buildSrc插件
独立项目
创建独立的插件项目具有更强的灵活性,能让更多的工程使用这个插件,但流程也会相对复杂一点.这里不再具体讲解,可以参考Gradle 自定义 plugin

参考资料:

https://www.jianshu.com/p/6dc2074480b8
https://www.jianshu.com/p/bcaf9a269d96
https://juejin.im/entry/59918304518825489151732d
https://www.jianshu.com/p/0acdb31eef2d
https://juejin.im/post/5cc5929bf265da036706b350
https://doc.yonyoucloud.com/doc/wiki/project/GradleUserGuide-Wiki/gradle_plugins/binary_plugins.html

一款快速生成安卓shape的插件--NoCodeShape

一、NoCodeShape介绍

NoCodeShape是一个能可视化界面操作生成Android中Shape.xml的Android Studio插件。对于新手能更好的去接受Android中相对应的属性,对于资深程序员则能简化操作,快速生成shape.xml。

二、NoCodeShape使用方法

1. 下载和安装

同一般的Android Studio插件下载一样,可以直接Preferences->Plugins 搜索 NoCodeShape搜索出来结果直接安装再重启即可。
在这里插入图片描述
也可以去jetbrains插件管理的官网下载各个版本的插件
https://plugins.jetbrains.com/plugin/13325-nocodeshape/versions
然后Preferences->Plugins 再Install plugins from disk从本地安装

2. 如何使用

在新建一个shape.xml文件后,右键选择NoCodeShape或者直接按快捷键Common+U
在这里插入图片描述
然后选择自己想要的属性,随着点击事件的进行会生成相关xml代码,并在Android Studio右边有对应的shape形状的展示。如果对应shape.xml有相关属性,NoCodeShape也会生成对应shape.xml属性的操作界面,非常方便。
示例:
在这里插入图片描述

三、实现原理

实现原理总体来说并不复杂,主要是界面相关操作逻辑比较繁琐。

对于新生成的一个shape.xml来说只需要弹出一个新的操作界面,用户只需要点击对应模块的属性即可。项目使用了单例模式+Bulider建造者模式去管理各个Shape属性,分别生成 Shape、Solid、Corners、Stroke、Gradient的单例,其内部拥有一个Builder用来去承各类型的具体属性。

通过界面的操作,对其内部的Builder进行数据的填充,最后在完成各类操作后,将各类型中的Builder中的所有属性提取出并生成一份完整的xml字符串并将其粘贴到Android Studio的操作界面上。

实现主要分为两大类:

1. 拼接生成xml字符串

字符串拼接算是其中最复杂的部分,一是各shape的类型拥有较多数据,其中一些属性有逻辑存在性,二是生成最终Android Studio的xml字符串的时候格式存在比较多的处理。对于各类属性都继承于`BaseXml`,其内部拥有一个静态内部`Builder`类,以相对简单的Solid类来举例,如下所示:
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
public class Solid extends BaseXml {

private static Builder builder;
private static Solid instance = null;

public static Solid getInstance() {
if (instance == null) {
builder = new Builder();
instance = new Solid();
}
return instance;
}

public static class Builder extends BaseBuilder {
String color;
String colorValue;

public void setColor(String color) {
this.colorValue = color;
this.color = getAttrWithOutUnitStr("color", color);
}
@Override
public String getBuilderString() {
return StringUtils.getString(color);
}

@Override
public void clearData() {
StringUtils.clearObjectData(this);
}

@Override
public void analysisAttribute(Attributes attributes) {
Solid.getInstance().setChecked(true);
setColor(attributes.getValue("android:color"));
}
}

其类继承于抽象类BaseXml,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public abstract class BaseXml {
private boolean isChecked = false;
public String getCloser() {
return " />";
}
public String getStartTag() {
return "";
}
public String generateXmlString() {
return "";
}
protected String getLineFeedString() {
return "\n";
}
public boolean isChecked() {
return isChecked;
}
public BaseXml setChecked(boolean checked) {
isChecked = checked;
return this;
}
}

抽象类提取出来在字符串拼接阶段,各类型常用的基本操作,例如:返回“<solid”这类的开始标签,” />”结束标签等

其内部BaseXml拥有对应类拥有的所有属性的常用操作,其继承与抽象类BaseBuilder,代码如下所示

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
public abstract class BaseBuilder {
public abstract String getBuilderString();
public abstract void clearData();
public abstract void analysisAttribute(Attributes attributes);
protected final String getAttrWithUnitStr(String attributeType, String value) {
String unit;
if (TextUtils.isEmpty(value)) {
return "";
}
if (value.contains("px") || value.contains("dp")) {
unit = "";
} else {
unit = DefaultData.UNIT;
}
return "android:" + attributeType + "=\"" + value + unit + "\"";
}
protected final String getAttrWithOutUnitStr(String attributeType, String value) {
if (TextUtils.isEmpty(value)) {
return "";
}
return "android:" + attributeType + "=\"" + value + "\"";
}
protected final String getValueOutUnit(String value) {
if (TextUtils.isEmpty(value)) {
return value;
}
return value.replace("dp", "").replace("px", "");
}
}

BaseBuilder内部封装了一些属性的常用操作,例如生成:android:color="#FFFFFF"这样的字符串,获取是否带有单位的字串等。
并提供三个抽象方法:

1
2
3
public abstract String getBuilderString(); //获取Builder中所有属性拼接好的字符串
public abstract void clearData(); //清空Builder内部属性值
public abstract void analysisAttribute(Attributes attributes); //分析xml数据中的值,这个在第二大点“将原有shape.xml字符串转化为对应操作界面”中将会讲到

以上是基本数据的构造,在最后在控件交互的地方会调用CommonAction类的refreshAndWriteData

1
2
3
4
5
6
7
8
abstract class CommonAction {
JComponent component;
NoShapeDialog noShapeDialog;

void refreshAndWriteData() {
NoCodeShapeAction.callWriteData();
}
}

最后会调用基本的Action中的writeData()方法,其具体逻辑为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 数据写入xml文件
*/
private static void writeData() {
final Document document = FileDocumentManager.getInstance().getDocument(file);
if (document == null) {
try {
throw new Exception("Document对象为空");
} catch (Exception e) {
e.printStackTrace();
}
return;
}
new WriteCommandAction.Simple(project) {
@Override
protected void run() {
document.setText(XMLString.getInstance().generateXmlString());
//formatCode();
}
}.execute();
}

利用XMLString.getInstance().generateXmlString()获取各操作类型的所有属性将其拼接为一份完整的shape.xml文件的字段,最后调用插件系统的相关命令将字符串粘贴在系统对应的输入框中。

2. 将原有shape.xml字符串转化为对应操作界面

上面已经讲述类如何拼接生成xml字符串并将其粘贴到Android Studio界面上,此外NoCodeShape不仅支持新生成的shape.xml,同样也支持对旧shape.xml进行修改的能力。与第一步相比较主要多了一步读取Android Studio xml文档并将其转化为对应操作界面的过程。其主要是在操作界面初始化之前执行了如下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
private void initSax() {
String text = FileDocumentManager.getInstance().getDocument(file).getText();
ShapeSaxHandler handler = new ShapeSaxHandler();
try {
handler.createViewList(text);
} catch (ParserConfigurationException e) {
e.printStackTrace();
} catch (SAXException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}

利用ShapeSaxHandler去解析xml里面的元素,参考了FindViewByMe的解析原理。

具体操作逻辑如下:

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
public void createViewList(String string) throws ParserConfigurationException, SAXException, IOException {
InputStream xmlStream = new ByteArrayInputStream(string.getBytes("UTF-8"));
SAXParserFactory factory = SAXParserFactory.newInstance();
SAXParser parser = factory.newSAXParser();
parser.parse(xmlStream, this);
}
@Override
public void startDocument() throws SAXException {
if (shapePartList == null) {
shapePartList = new ArrayList<ShapePart>();
}
}
@Override
public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
switch (qName) {
case "shape":
Shape.getInstance().getBuilder().analysisAttribute(attributes);
break;
case "stroke":
Stroke.getInstance().getBuilder().analysisAttribute(attributes);
break;
case "solid":
Solid.getInstance().getBuilder().analysisAttribute(attributes);
break;
case "gradient":
Gradient.getInstance().getBuilder().analysisAttribute(attributes);
break;
case "corners":
Corners.getInstance().getBuilder().analysisAttribute(attributes);
break;
default:
break;
}

逻辑其实很清楚,主要对startTag的判断,然后通过调用 public abstract void analysisAttribute(Attributes attributes);的方法对相应的 类型的Buidler进行一个赋值操作。

以Stroke为例:

1
2
3
4
5
6
7
8
@Override
public void analysisAttribute(Attributes attributes) {
Stroke.getInstance().setChecked(true);
setColor(attributes.getValue("android:color"));
setDashGap(attributes.getValue("android:dashGap"));
setWidth(attributes.getValue("android:width"));
setDashWidth(attributes.getValue("android:dashWidth"));
}

主要是获取到其中的属性,并对初始化的界面进行一些操作(例如选中或者赋值相关操作)。

四、总结

这个插件算是自己第一次做一个相对较实用的插件,都是利用工作的空闲事件进行编写,前前后后进行了将近一个月,其中收获颇多,但也踩过了各种各样的坑。在开发过程中由于相关文档较少,通过阅读官方文档还是有点小吃力,不过一步步还是走了下来,自己也得到了成长。其中坑也都添平了,但由于一些基础技术的欠缺,比如对Java GUI界面编程不太熟悉,导致开发过程中有很大一段时间都在跟界面作对,因此后面有机会将去深入了解Java的界面编程,争取能够将页面交互能够做到更好。

另外由于自身开发精力的原因,不能将插件做到完美,目前插件中还有如下几个问题:

1、Gradient中对应相关逻辑还需要再优化
2、还未支持Size跟Pading(从自身所处环境考虑用得较少,所以暂未支持)
3、对于颜色选择器默认打开后不支持对本地颜色字符串处理
4、存在大量的界面操作逻辑代码,需要优化

最后,希望大家能在使用过程中提出相关的意见或建议,也欢迎能一起加入到开发中,从而能将该插件做得更加完美。

项目地址:

https://github.com/VomPom/NoCodeShape

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×