greendao缓存源码分析

GreenDao是Android中使用比较广泛的一个orm数据库,以高效和便捷著称。在项目开发过程中遇到过好几次特别奇葩的问题,最后排查下来,发现还是由于不熟悉它的缓存机制引起的。下面是自己稍微阅读了下它的源码后做的记录,避免以后发现类似的问题。

缓存机制相关源码

DaoMaster

DaoMaster是GreenDao的入口,它的继承自AbstractDaoMaster,有三个重要的参数,分别是实例、版本和Dao的信息。

1
2
3
4
5
6
7
8
//数据库示例
protected final SQLiteDatabase db;

//数据库版本
protected final int schemaVersion;

//dao和daoconfig的配置
protected final Map<Class<? extends AbstractDao<?, ?>>, DaoConfig> daoConfigMap;

DaoMaster中还有两个重要的方法:createAllTables和dropAllTables,和一个抽象的OpenHelper类,该类继承自系统的SQLiteOpenHelper类,主要用于数据库创建的时候初始化所有数据表。

创建DaoMaster需要传入SQLiteDatabase的实例,一般如下创建:

1
mDaoMaster = new DaoMaster(helper.getWritableDatabase())

跟踪代码可知数据库的初始化和升降级都是在调用helper.getWritableDatabase()时执行的。相关代码在SQLiteOpenHelper类中。

在getWritableDatabase方法中会调用getDatabaseLocked方法

1
2
3
4
5
public SQLiteDatabase getWritableDatabase() {
synchronized (this) {
return getDatabaseLocked(true);
}
}

getDatabaseLocked方法如下

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
private SQLiteDatabase getDatabaseLocked(boolean writable) {
// 首先方法接收一个是否可读的参数
if (mDatabase != null) {
if (!mDatabase.isOpen()) {
//数据库没有打开,关闭并且置空
mDatabase.close().
mDatabase = null;
} else if (!writable || !mDatabase.isReadOnly()) {
//只读或者数据库已经是读写状态了,则直接返回实例
return mDatabase;
}
}

if (mIsInitializing) {
throw new IllegalStateException("getDatabase called recursively");
}

SQLiteDatabase db = mDatabase;
try {
mIsInitializing = true;

if (db != null) {
if (writable && db.isReadOnly()) {
//只读状态的时候打开读写
db.reopenReadWrite();
}
} else if (mName == null) {
db = SQLiteDatabase.create(null);
} else {
//创建数据库实例
。。。代码省略。。。

//调用子类的onConfigure方法
onConfigure(db);

final int version = db.getVersion();
if (version != mNewVersion) {
if (db.isReadOnly()) {
throw new SQLiteException("Can't upgrade read-only database from version " +
db.getVersion() + " to " + mNewVersion + ": " + mName);
}

db.beginTransaction();
try {
if (version == 0) {
// 如果版本为0的时候初始化数据库,调用子类的onCreate方法。
onCreate(db);
} else {
//处理升降级
if (version > mNewVersion) {
onDowngrade(db, version, mNewVersion);
} else {
onUpgrade(db, version, mNewVersion);
}
db.setVersion(mNewVersion);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}

onOpen(db);

if (db.isReadOnly()) {
Log.w(TAG, "Opened " + mName + " in read-only mode");
}

mDatabase = db;
return db;
} finally {
mIsInitializing = false;
if (db != null && db != mDatabase) {
db.close();
}
}}
}

greendao的缓存到底是如何实现的呢?

DaoMaster构造方法中会把所有的Dao类注册到Map中,每个Dao对应一个DaoConfig配置类。

1
2
3
4
protected void registerDaoClass(Class<? extends AbstractDao<?, ?>> daoClass) {
DaoConfig daoConfig = new DaoConfig(db, daoClass);
daoConfigMap.put(daoClass, daoConfig);
}

DaoConfig是对数据库表的一个抽象,有数据库实例、表名、字段列表、SQL statements等类变量,最重要的是IdentityScope,它是GreenDao实现数据缓存的关键。在DaoSession类初始化的时候IdentityScope初始化,可以根据参数IdentityScopeType.Session和IdentityScopeType.None来配置是否开启缓存。

IdentityScope接口有两个实现类,分别是IdentityScopeLong和IdentityScopeObject,它们的实现类似,都是维护一个Map存放key和value,然后有一些put、get、remove、clear等方法,最主要的区别是前者的key是long,可以实现更高的读写效率,后面的key是Object。

判断主键字段类型是否是数字类型,如果是的话则使用IdentityScopeLong类型来缓存数据,否则使用IdentityScopeObject类型。

1
keyIsNumeric = type.equals(long.class) || type.equals(Long.class) || type.equals(int.class)|| type.equals(Integer.class) || type.equals(short.class) || type.equals(Short.class)|| type.equals(byte.class) || type.equals(Byte.class);
1
2
3
4
5
6
7
8
9
10
11
12
13
public void initIdentityScope(IdentityScopeType type) {
if (type == IdentityScopeType.None) {
identityScope = null;
} else if (type == IdentityScopeType.Session) {
if (keyIsNumeric) {
identityScope = new IdentityScopeLong();
} else {
identityScope = new IdentityScopeObject();
}
} else {
throw new IllegalArgumentException("Unsupported type: " + type);
}
}

缓存的使用

数据读取

以Query类中list方法为例,跟踪代码可知,最后会调用AbstractDao的loadCurrent方法,它首先会根据主键判断dentityScope中有没有对应的缓存,如何有直接返回,如果没有才会读取Cursor里面的数据。

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
final protected T loadCurrent(Cursor cursor, int offset, boolean lock) {
if (identityScopeLong != null) {
if (offset != 0) {
// Occurs with deep loads (left outer joins)
if (cursor.isNull(pkOrdinal + offset)) {
return null;
}
}
//读取主键
long key = cursor.getLong(pkOrdinal + offset);
//读取缓存
T entity = lock ? identityScopeLong.get2(key) :identityScopeLong.get2NoLock(key);
if (entity != null) {
//如果有,直接返回
return entity;
} else {
//如果没有,读取游标中的值
entity = readEntity(cursor, offset);
attachEntity(entity);
//把数据更新到缓存中
if (lock) {
identityScopeLong.put2(key, entity);
} else {
identityScopeLong.put2NoLock(key, entity);
}
return entity;
}
} else if (identityScope != null) {
K key = readKey(cursor, offset);
if (offset != 0 && key == null) {
// Occurs with deep loads (left outer joins)
return null;
}
T entity = lock ? identityScope.get(key) : identityScope.getNoLock(key);
if (entity != null) {
return entity;
} else {
entity = readEntity(cursor, offset);
attachEntity(key, entity, lock);
return entity;
}
} else {
// Check offset, assume a value !=0 indicating a potential outer join, so check PK
if (offset != 0) {
K key = readKey(cursor, offset);
if (key == null) {
// Occurs with deep loads (left outer joins)
return null;
}
}
T entity = readEntity(cursor, offset);
attachEntity(entity);
return entity;
}
}

数据删除
我们经常使用DeleteQuery的executeDeleteWithoutDetachingEntities来条件删除数据,这时候是不清除缓存的,当用主键查询的时候,还是会返回缓存中的数据。

Deletes all matching entities without detaching them from the identity scope (aka session/cache). Note that this method may lead to stale entity objects in the session cache. Stale entities may be returned when loaded by their primary key, but not using queries.

使用对象的方式删除数据的时候,比如deleteInTx()等面向对象的方法时,会删除对应的缓存。在AbstractDao中deleteInTxInternal方法里面,会调用identityScope的remove方法。

1
2
3
if (keysToRemoveFromIdentityScope != null && identityScope != null) {
identityScope.remove(keysToRemoveFromIdentityScope);
}

数据插入

以insert方法为例,它会在插入成功之后,调用attachEntity方法,存放缓存数据。

1
2
3
4
5
6
7
8
9
10
protected final void attachEntity(K key, T entity, boolean lock) {
attachEntity(entity);
if (identityScope != null && key != null) {
if (lock) {
identityScope.put(key, entity);
} else {
identityScope.putNoLock(key, entity);
}
}
}

数据更新

数据update的时候也会调用attachEntity方法。

缓存带来的坑和脱坑方案
1.触发器引起的数据不同步
我们在项目中有这么一个需求,当改变A对象的a字段的时候,要同时改变B对象的b字段,触发器代码类似如下。

1
2
3
String sql = "create trigger 触发器名 after insert on 表B "
+ "begin update 表A set 字段A.a = NEW. 字段B.b where 字段A.b = NEW.字段B.c; end;";
db.execSQL(sql);

b是A的外键,映射到表B的b字段。

这样设置触发器之后,更新表B数据的时候,会自动把更新同步到表A,但是这样其实没有更新表A对应DAO的缓存,当查询表A的时候还是更新前的数据。

解决方案:
1.在greendao2.x版本中,可以暴露DaoSession中对应的DaoConfig
,然后调用daoConfig.clearIdentityScope();在3.x版本中可以直接调用dao类的detachAll方法,它会清除所有的缓存。 同时也可以调用Entity的refresh方法来刷新缓存。

1
2
3
4
5
public void detachAll() {
if (identityScope != null) {
identityScope.clear();
}
}

上面的方法都是通过清除缓存来保证数据的同步性,但是频繁的清除缓存就大大影响数据查询效率,不建议这么使用。

2.尽量不要使用触发器,最好使用greenDao自带的一些接口,绝大部分情况下都是能满足要求的。对于能否使用触发器,开发者做了解释。

greenDAO uses a plain SQLite database. To use triggers you have to do a regular raw SQL query on your database. greenDAO can not help you there.

2.自定义SQL带来的数据不同步问题
项目中即使使用了GreenDao,我们还是免不了使用自定义的sql语句来操作数据库,类似下面较复杂的查询功能。

1
2
3
String sql = "select *, count(distinct " + columnPkgName + ") from " + tableName + " where STATUS = 0" + " group by " + columnPkgName
+ " order by " + columnTimestamp + " desc limit " + limitCount + " offset 0;";
Cursor query = mDaoMaster.getDatabase().rawQuery(sql, new String[] {});

这种查询语句除了没有使用GreenDao的缓存,其它倒是没有什么问题。但是一旦使用update或者delete等接口时,就会引起数据的不同步,因为数据库里面的数据更新了,但是greenDao里面的缓存还是旧的。

总结:使用第三方库的时候,最好能够深入理解它的代码,不然遇到坑了都不知道怎么爬出来,像greendao这种,由于自己不合理使用导致的问题还是很多的。

h5实现视频自动播放

最近项目中,需要用到网页视频自动播放功能,PC端没有问题,移动端无法自动播放。针对这个场景和网上资料查阅,分析各个方案优缺点,方便其他相似需求开发人员。

video标签说明

  • src: 设置显示视频路径
  • controls: 显示控制栏
  • loop: 控制视频循环播放
  • autoplay: 自动播放
  • muted: 设置静音播放
  • playsinline: 不全屏播放

PC端自动播放

1
2
3
4
5
6
7
8
<video id="video"
x5-video-player-type="h5" controls autoplay
controlsList="nofullscreen" playsinline
style="width: 100%; height:300px">
<source src="/video/test.mp4" type="video/mp4">
<source src="/video/test.webm" type="video/webm">
<p>Your browser doesn't support HTML5 video. Here is</p>
</video>

通过指定autoplay属性,即可实现自动播放

移动端自动播放分析

为了避免浪费流量和电池功耗损失,移动端浏览器禁止了自动播放功能,需要用户主动点击才会播放。

youtubo视频网站自动播放

针对视频访问量大网站,浏览器厂商有个白名单机制,放入白名单的域名,就能自动播放。参考链接https://blog.google/products/chrome/improving-autoplay-chrome/

设置autoplay、muted和playsinline

对video标签进行如下设置:

1
2
3
4
5
6
<video id="video" muted autoplay playsinline 
style="width: 100%; height:300px">
<source src="/video/test.mp4" type="video/mp4">
<source src="/video/test.webm" type="video/webm">
<p>Your browser doesn't support HTML5 video. Here is</p>
</video>

各个浏览器厂商表现不一样,具体如下:

平台/浏览器 自带 微信
iOS >= 10.0(关闭低功耗开关)
iOS < 10.0
Android
华为
PC

微信App自动播放(不点击播放器按钮)

在微信App中,h5通过集成微信sdk,并监听SDK初始化桥事件,然后开启播放器播放。设置如下:

  1. 设置SDK
1
2
3
4
5
6
7
8
9
10
11
<script src="//res.wx.qq.com/open/js/jweixin-1.0.0.js"></script>
<script>
window.wxInited = false
document.addEventListener('WeixinJSBridgeReady', e => {
const video = document.getElementById('video')
if (video) {
video.play()
video.pause()
}
}, false)
</script>
  1. 设置video标签
1
2
3
4
5
6
7
<video id="video" x5-video-player-type="h5"
controls controlsList="nofullscreen"
playsinline style="width: 100%; height:0px">
<source src="/video/test.mp4" type="video/mp4">
<source src="/video/test.webm" type="video/webm">
<p>Your browser doesn't support HTML5 video. Here is</p>
</video>

通过上述设置,播放器处于可播放状态,后续非人工触发play或pause操作,都可以操作播放器。

各个浏览器厂商表现一致,具体如下:

平台/浏览器 自带 微信
iOS
Android
华为
PC

人工触发事件(不点击播放器按钮)

除了点击播放器的播放或暂停按钮,可以触发播放操作。用户主动点击(click)事件或滑动(touchstart、touchmove、touchend)事件,可以触发播放器播放操作。设置如下:

1
2
3
4
5
6
7
8
document.addEventListener('click', function () {
const video = document.getElementById('video')
if (video) {
video.play()
}
},
{ capture: true, passive: true }
)

各个浏览器厂商表现一致,具体如下:

平台/浏览器 自带 微信
iOS
Android
华为
PC

非视频降级方案

根据上述的分析,每种方案都有些不足,并不能解决所有平台和浏览器的问题。针对静音的视频环境,可以把视频转化为图片,按照一定的周期播放图片,这样所有的平台都支持。

这里参考了「张鑫旭大佬」的方案,实现原理如下:

  1. 图片DOM对象预加载,放在内存中;
  2. 播放开始,页面append当前图片DOM,同时移除上一帧DOM图片(如果有),保证页面中仅有一个图片序列元素; 对,很简单,没什么高超的技巧,但就是这种实现策略,对页面的开销是最小的,最终运行体验是最好的。
1
2
3
4
5
6
7
8
<div class="page" style="background-image: url(&quot;https://website.didiglobal.com/dist/img/homepage1-app.fb9b0c10.jpg&quot;);">
<div class="home-video-wrap">
<div class="video-app-container">
<img src="https://website.didiglobal.com/dist/img/合成 1_00315.68c3cb63.jpg">
</div>
<div class="mask-whole"></div>
</div>
</div>

图片轮播demo,参考滴滴移动官网

视频转为图片

方案如下:

  1. 电脑打开premiere cc 2017,导入视频编辑好。 img
  2. 编辑好视频后,按导出快捷键Ctrl+M调出导出页面,然后格式选择JPEG格式。 img

3.点击输出名称选择保存路径。

img

4.设置好格式和保存路径后,点击底部的导出,等待图片的导出就可以了。 img

移动端自动播放兼容方案

自动播放需要满足几个条件所有移动端手机都可以自动播放:

  1. 必须要有用户点击事件touch
  2. 接收点击事件的标签和video标签不能是父子关系(就是body接收的点击事件调用video的play方法,播放器也是不会播放视频的
  3. 接收点击时候后,立即执行play()方法,网络请求后调用play()方法可能会无效

因此,页面初始化话的时候写一个video标签,这个video标签在可见区域之外,video标签和需要点击事件控件不是父子关系,当用户点击播放按钮时,立刻调用play()方法。设置video标签的样式到可见区域内,通过z-index控制层级关系,就能实现自动播放。

根据上述方案分析,选择合适自己的使用场景,同时做好兼容,如

  1. 当不支持自动播放时,通过文案提示或手势引导,提示用户点击播放

参考说明

PC端、移动端Video自动播放兼容完美解决方案(IOS、安卓、微信端)
移动端视频 video 的 Android 兼容,去除播放控件、全屏等