MMKV源码简析---初始化

初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
String dir = getFilesDir().getAbsolutePath() + "/mmkv";
String rootDir = MMKV.initialize(dir, new MMKV.LibLoader() {
@Override
public void loadLibrary(String libName) {
ReLinker.loadLibrary(MyApplication.this, libName);
}
}, MMKVLogLevel.LevelInfo);
Log.i("MMKV", "mmkv root: " + rootDir);

// set log level
MMKV.setLogLevel(MMKVLogLevel.LevelInfo);

// you can turn off logging
//MMKV.setLogLevel(MMKVLogLevel.LevelNone);

MMKV.registerHandler(this);
MMKV.registerContentChangeNotify(this);

首先从MMKV#initialize开始

MMKV.initialize

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static String initialize(Context context) {
String root = context.getFilesDir().getAbsolutePath() + "/mmkv";
MMKVLogLevel logLevel = BuildConfig.DEBUG ? MMKVLogLevel.LevelDebug : MMKVLogLevel.LevelInfo;
return initialize(root, null, logLevel);
}

public static String initialize(String rootDir, LibLoader loader, MMKVLogLevel logLevel) {
if (loader != null) {
if (BuildConfig.FLAVOR.equals("SharedCpp")) {
loader.loadLibrary("c++_shared");
}
loader.loadLibrary("mmkv");
} else {
if (BuildConfig.FLAVOR.equals("SharedCpp")) {
System.loadLibrary("c++_shared");
}
System.loadLibrary("mmkv");
}
MMKV.rootDir = rootDir;
jniInitialize(MMKV.rootDir, logLevel2Int(logLevel));
return rootDir;
}

通过 jniInitialize 来到 JNI 层,找到native-bridge.cpp中的mmkv::jniInitialize->MMKV::initializeMMKV(kStr, logLevel)

1
2
3
4
5
6
7
8
9
10
void MMKV::initializeMMKV(const MMKVPath_t &rootDir, MMKVLogLevel logLevel) {
g_currentLogLevel = logLevel;

ThreadLock::ThreadOnce(&once_control, initialize); // #1

g_rootDir = rootDir;
mkPath(g_rootDir);

MMKVInfo("root dir: " MMKV_PATH_FORMAT, g_rootDir.c_str());
}

ThradLocal::ThreadOnce 就是对 pthread_once 的一次封装,pthread_once 会对首次调用此函数的线程拉起一个init_routine调用,此处即 initialize 这个函数

1
2
3
4
5
6
7
8
void initialize() {
g_instanceDic = new unordered_map<string, MMKV *>;
g_instanceLock = new ThreadLock();
g_instanceLock->initialize();

mmkv::DEFAULT_MMAP_SIZE = mmkv::getPageSize();
MMKVInfo("page size:%d", DEFAULT_MMAP_SIZE);
}

创建了一个全局 MMKV 映射表和一个全局 ThreadLock 对象,ThreadLock 就是对 pthread 的封装; 同时将系统的 PAGE_SIZE 记录在mmkv::DEFAULT_MMAP_SIZE

1
2
3
4
5
6
7
8
9
ThreadLock::ThreadLock() {
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);

pthread_mutex_init(&m_lock, &attr);

pthread_mutexattr_destroy(&attr);
}

MMKV 写

典型调用如下:

1
2
3
4
5
6
7
8
9
val mmkv = MMKV.mmkvWithID("testKotlin")
mmkv.encode("bool", true)
println("bool = " + mmkv.decodeBool("bool"))

mmkv.encode("int", Integer.MIN_VALUE)
println("int: " + mmkv.decodeInt("int"))

mmkv.encode("long", java.lang.Long.MAX_VALUE)
println("long: " + mmkv.decodeLong("long"))

MMKV.mmkvWithID

MMKV.mmkvWithID本质调用的是getMMKVWithID这个接口

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
public static MMKV mmkvWithID(String mmapID) {
if (rootDir == null) {
throw new IllegalStateException("You should Call MMKV.initialize() first.");
}
long handle = getMMKVWithID(mmapID, SINGLE_PROCESS_MODE, null, null);
return new MMKV(handle);
}

public static MMKV mmkvWithID(String mmapID, int mode) {
if (rootDir == null) {
throw new IllegalStateException("You should Call MMKV.initialize() first.");
}

long handle = getMMKVWithID(mmapID, mode, null, null);
return new MMKV(handle);
}

// cryptKey's length <= 16
public static MMKV mmkvWithID(String mmapID, int mode, String cryptKey) {
if (rootDir == null) {
throw new IllegalStateException("You should Call MMKV.initialize() first.");
}

long handle = getMMKVWithID(mmapID, mode, cryptKey, null);
return new MMKV(handle);
}

@Nullable
public static MMKV mmkvWithID(String mmapID, String relativePath) {
if (rootDir == null) {
throw new IllegalStateException("You should Call MMKV.initialize() first.");
}

long handle = getMMKVWithID(mmapID, SINGLE_PROCESS_MODE, null, relativePath);
if (handle == 0) {
return null;
}
return new MMKV(handle);
}

private native static long getMMKVWithID(String mmapID, int mode, String cryptKey, String relativePath);

我们注意到这里有几个参数

mmapID 比较容易理解,应该是一个全局 id,联想初始化时构造的 map,应该是在那里使用。mode 这个跟跨进程模式有关,后续分开分析, cryptKey 为加密用的 key,relativePath 则是表示相对与 rootDir 的路径, 通过 getMMKVWithID 创建的 native 对象通过句柄的方式被 Java 层的 MMKV 对象持有,进行 JNI 通信

JNI getMMKVWithID

通过native-bridge.cpp找到 JNI 转发的实现MMKV::mmkvWithID

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

/**
*
* @param mmapID JAVA层传入的mmkvid
* @param size MMKV::DEFAULT_MMAP_SIZE
* @param mode 1代表单进程模式,2代表多进程模式
* @param cryptKey Java层传入加密用的key,可以为nullptr
* @param relativePath Java层传入的相对路径
* @return
*/
MMKV *MMKV::mmkvWithID(const string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath) {

if (mmapID.empty()) {
return nullptr;
}
SCOPED_LOCK(g_instanceLock);

auto mmapKey = mmapedKVKey(mmapID, relativePath);
auto itr = g_instanceDic->find(mmapKey);
if (itr != g_instanceDic->end()) {
MMKV *kv = itr->second;
return kv;
}
if (relativePath) {
if (!isFileExist(*relativePath)) {
if (!mkPath(*relativePath)) {
return nullptr;
}
}
MMKVInfo("prepare to load %s (id %s) from relativePath %s", mmapID.c_str(), mmapKey.c_str(),
relativePath->c_str());
}
auto kv = new MMKV(mmapID, size, mode, cryptKey, relativePath);
(*g_instanceDic)[mmapKey] = kv;
return kv;
}
SCOPED_LOCK

SCOPED_LOCK是一个宏展开后,得到是一个 ScopedLock 对象,通过 RAII 的方式实现在函数内上锁, 其中用到了一个特殊宏__COUNTER__

1
2
3
4
#define SCOPED_LOCK(lock) _SCOPEDLOCK(lock, __COUNTER__)
#define _SCOPEDLOCK(lock, counter) __SCOPEDLOCK(lock, counter)
#define __SCOPEDLOCK(lock, counter) \
mmkv::ScopedLock<std::remove_pointer<decltype(lock)>::type> __scopedLock##counter(lock)
1
2
__COUNTER__
Defined to an integer value that starts at zero and is incremented each time the __COUNTER__ macro is expanded.

OK,每次展开自动+1, 那么使用这种方式定义就做到了每个 ScopedLock 对象变量不重名

P.S remove_pointer

1
Provides the member typedef type which is the type pointed to by T, or, if T is not a pointer, then type is the same as T.

简单来说就是拿到指针的原类型,例如int*->int

mmapedKVKey 和全局映射表绑定

接着上文分析,查看 mmapedKVKey 得知 MMKV 根据 mmapID 和 relativePath 的 md5,生成了一个 id,根据这个 id 在全局 g_instanceDic 进行了 putIfAbsent 操作,同时返回 new 出来的对象

MMKV 构造函数
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
MMKV::MMKV(const string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath)
: m_mmapID(mmapedKVKey(mmapID, relativePath)) // historically Android mistakenly use mmapKey as mmapID
, m_path(mappedKVPathWithID(m_mmapID, mode, relativePath))
, m_crcPath(crcPathWithID(m_mmapID, mode, relativePath))
, m_file(new MemoryFile(m_path, size, (mode & MMKV_ASHMEM) ? MMFILE_TYPE_ASHMEM : MMFILE_TYPE_FILE))
, m_metaFile(new MemoryFile(m_crcPath, DEFAULT_MMAP_SIZE, m_file->m_fileType))
, m_metaInfo(new MMKVMetaInfo())
, m_crypter(nullptr)
, m_lock(new ThreadLock())
, m_fileLock(new FileLock(m_metaFile->getFd(), (mode & MMKV_ASHMEM)))
, m_sharedProcessLock(new InterProcessLock(m_fileLock, SharedLockType))
, m_exclusiveProcessLock(new InterProcessLock(m_fileLock, ExclusiveLockType))
, m_isInterProcess((mode & MMKV_MULTI_PROCESS) != 0 || (mode & CONTEXT_MODE_MULTI_PROCESS) != 0) {
m_actualSize = 0;
m_output = nullptr;

if (cryptKey && cryptKey->length() > 0) {
m_crypter = new AESCrypt(cryptKey->data(), cryptKey->length());
}

m_needLoadFromFile = true;
m_hasFullWriteback = false;

m_crcDigest = 0;

m_sharedProcessLock->m_enable = m_isInterProcess;
m_exclusiveProcessLock->m_enable = m_isInterProcess;

// sensitive zone
{
SCOPED_LOCK(m_sharedProcessLock);
loadFromFile();
}
}

一段段看

m_mmapID 应该和 mmap 中使用的 id 有关,这个值目前看应该和[mmapedKVKey 和全局映射表绑定]中提到的 id 一致

m_path通过mappedKVPathWithID得到

1
2
3
4
5
6
7
8
9
10
11
12
MMKVPath_t mappedKVPathWithID(const string &mmapID, MMKVMode mode, MMKVPath_t *relativePath) {
#ifndef MMKV_ANDROID
if (relativePath) {
#else
if (mode & MMKV_ASHMEM) {
return ashmemMMKVPathWithID(encodeFilePath(mmapID));
} else if (relativePath) {
#endif
return *relativePath + MMKV_PATH_SLASH + encodeFilePath(mmapID);
}
return g_rootDir + MMKV_PATH_SLASH + encodeFilePath(mmapID);
}

看来跟匿名内存模式有关,做了一层 Ashmem 的兼容

m_crcPath则类似 mappedKVPathWithID 的实现,只是对路径名做了特殊符号 md5 后,再加上了一个.crc 后缀, 暂时还不知道什么作用,先放着

m_file则是一个 MemoryFile 对象,m_metaFile是一个以m_crcPath为路径的 MemoryFile

m_metaInfo是一个 MMKVMetaInfo 结构体,记录了一些元数据,等到读写相关信息的时候再看,m_lockm_fileLockm_sharedProcessLockm_exclusiveProcessLock均为锁对象

构造器根据传入的 cryptKey 创建了 AESCrypt 对象, 同时进行loadFromFile调用

MemoryFile

首先来看看MemoryFile这个类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
MemoryFile::MemoryFile(const string &path, size_t size, FileType fileType)
: m_name(path), m_fd(-1), m_ptr(nullptr), m_size(0), m_fileType(fileType) {
if (m_fileType == MMFILE_TYPE_FILE) {
reloadFromFile();
} else {
// round up to (n * pagesize)
if (size < DEFAULT_MMAP_SIZE || (size % DEFAULT_MMAP_SIZE != 0)) {
size = ((size / DEFAULT_MMAP_SIZE) + 1) * DEFAULT_MMAP_SIZE;
}
auto filename = m_name.c_str();
auto ptr = strstr(filename, ASHMEM_NAME_DEF);
if (ptr && ptr[sizeof(ASHMEM_NAME_DEF) - 1] == '/') {
filename = ptr + sizeof(ASHMEM_NAME_DEF);
}
m_fd = ASharedMemory_create(filename, size);
if (m_fd >= 0) {
m_size = size;
auto ret = mmap();
if (!ret) {
doCleanMemoryCache(true);
}
}
}
}

这里对 FileType 做了区分,如果是MMFILE_TYPE_FILE,则是走文件 IO,否则,则是 ASHMEM 匿名内存文件,我们这里分析下文件 IO 的情况

  • MMFILE_TYPE_FILE
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
void MemoryFile::reloadFromFile() {
if (m_fileType == MMFILE_TYPE_ASHMEM) {
return;
}
if (isFileValid()) {
MMKVWarning("calling reloadFromFile while the cache [%s] is still valid", m_name.c_str());
MMKV_ASSERT(0);
clearMemoryCache();
}

m_fd = open(m_name.c_str(), O_RDWR | O_CREAT | O_CLOEXEC, S_IRWXU);
if (m_fd < 0) {
MMKVError("fail to open:%s, %s", m_name.c_str(), strerror(errno));
} else {
FileLock fileLock(m_fd);
InterProcessLock lock(&fileLock, ExclusiveLockType);
SCOPED_LOCK(&lock);

mmkv::getFileSize(m_fd, m_size);
// round up to (n * pagesize)
if (m_size < DEFAULT_MMAP_SIZE || (m_size % DEFAULT_MMAP_SIZE != 0)) {
size_t roundSize = ((m_size / DEFAULT_MMAP_SIZE) + 1) * DEFAULT_MMAP_SIZE;
truncate(roundSize);
} else {
auto ret = mmap();
if (!ret) {
doCleanMemoryCache(true);
}
}
// iOS spec code...
}
}

这个函数做了几件事情

  1. 打开路径为m_name的文件,记录于描述符m_fd
  2. 将文件对齐为 DEFAULT_PMM_SIZE 的整数倍大小
  3. mmap 打开内存映射,映射到 m_ptr
  • MMFILE_TYPE_ASHMEM

如果是匿名内存模式,执行逻辑类似,只是创建文件的实现换成了 ASharedMemory_create

至此,MemoryFile 初始化过程结束,回到 MMKV 的构造器中调用的 loadFromFile 函数

MMKV::loadFromFile
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
void MMKV::loadFromFile() {
if (m_metaFile->isFileValid()) {
m_metaInfo->read(m_metaFile->getMemory()); // 读元数据文件到内存对象m_metaInfo中
}
if (m_crypter) {
if (m_metaInfo->m_version >= MMKVVersionRandomIV) {
m_crypter->resetIV(m_metaInfo->m_vector, sizeof(m_metaInfo->m_vector));
}
}

if (!m_file->isFileValid()) {
m_file->reloadFromFile();
}
if (!m_file->isFileValid()) {
MMKVError("file [%s] not valid", m_path.c_str());
} else {
// error checking
bool loadFromFile = false, needFullWriteback = false;
checkDataValid(loadFromFile, needFullWriteback); // #1
MMKVInfo("loading [%s] with %zu actual size, file size %zu, InterProcess %d, meta info "
"version:%u",
m_mmapID.c_str(), m_actualSize, m_file->getFileSize(), m_isInterProcess, m_metaInfo->m_version);
auto ptr = (uint8_t *) m_file->getMemory();
// loading
if (loadFromFile && m_actualSize > 0) {
MMKVInfo("loading [%s] with crc %u sequence %u version %u", m_mmapID.c_str(), m_metaInfo->m_crcDigest,
m_metaInfo->m_sequence, m_metaInfo->m_version);
MMBuffer inputBuffer(ptr + Fixed32Size, m_actualSize, MMBufferNoCopy);
if (m_crypter) {
decryptBuffer(*m_crypter, inputBuffer);
}
clearDictionary(m_dic);
if (needFullWriteback) {
MiniPBCoder::greedyDecodeMap(m_dic, inputBuffer);
} else {
MiniPBCoder::decodeMap(m_dic, inputBuffer);
}
m_output = new CodedOutputData(ptr + Fixed32Size, m_file->getFileSize() - Fixed32Size);
m_output->seek(m_actualSize);
if (needFullWriteback) {
fullWriteback(); // #2 全量写回文件
}
} else {
// file not valid or empty, discard everything
SCOPED_LOCK(m_exclusiveProcessLock);

m_output = new CodedOutputData(ptr + Fixed32Size, m_file->getFileSize() - Fixed32Size);
if (m_actualSize > 0) {
writeActualSize(0, 0, nullptr, IncreaseSequence);
sync(MMKV_SYNC);
} else {
writeActualSize(0, 0, nullptr, KeepSequence);
}
}
MMKVInfo("loaded [%s] with %zu values", m_mmapID.c_str(), m_dic.size());
}

m_needLoadFromFile = false;
}

// MMKV初始化时, loadFromFile=false, needFullWriteback=false, m_metaInfo->m_version=MMKVVersionSequence
void MMKV::checkDataValid(bool &loadFromFile, bool &needFullWriteback) {
// try auto recover from last confirmed location
auto fileSize = m_file->getFileSize();
auto checkLastConfirmedInfo = [&] {
if (m_metaInfo->m_version >= MMKVVersionActualSize) {
// downgrade & upgrade support
uint32_t oldStyleActualSize = 0;
memcpy(&oldStyleActualSize, m_file->getMemory(), Fixed32Size);
if (oldStyleActualSize != m_actualSize) {
MMKVWarning("oldStyleActualSize %u not equal to meta actual size %lu", oldStyleActualSize,
m_actualSize);
if (oldStyleActualSize < fileSize && (oldStyleActualSize + Fixed32Size) <= fileSize) {
if (checkFileCRCValid(oldStyleActualSize, m_metaInfo->m_crcDigest)) {
MMKVInfo("looks like [%s] been downgrade & upgrade again", m_mmapID.c_str());
loadFromFile = true;
writeActualSize(oldStyleActualSize, m_metaInfo->m_crcDigest, nullptr, KeepSequence);
return;
}
} else {
MMKVWarning("oldStyleActualSize %u greater than file size %lu", oldStyleActualSize, fileSize);
}
}

auto lastActualSize = m_metaInfo->m_lastConfirmedMetaInfo.lastActualSize;
if (lastActualSize < fileSize && (lastActualSize + Fixed32Size) <= fileSize) {
auto lastCRCDigest = m_metaInfo->m_lastConfirmedMetaInfo.lastCRCDigest;
if (checkFileCRCValid(lastActualSize, lastCRCDigest)) {
loadFromFile = true;
writeActualSize(lastActualSize, lastCRCDigest, nullptr, KeepSequence);
} else {
MMKVError("check [%s] error: lastActualSize %u, lastActualCRC %u", m_mmapID.c_str(), lastActualSize,
lastCRCDigest);
}
} else {
MMKVError("check [%s] error: lastActualSize %u, file size is %u", m_mmapID.c_str(), lastActualSize,
fileSize);
}
}
};

m_actualSize = readActualSize();

if (m_actualSize < fileSize && (m_actualSize + Fixed32Size) <= fileSize) {
if (checkFileCRCValid(m_actualSize, m_metaInfo->m_crcDigest)) {
loadFromFile = true;
} else {
checkLastConfirmedInfo();

if (!loadFromFile) {
auto strategic = onMMKVCRCCheckFail(m_mmapID);
if (strategic == OnErrorRecover) {
loadFromFile = true;
needFullWriteback = true;
}
MMKVInfo("recover strategic for [%s] is %d", m_mmapID.c_str(), strategic);
}
}
} else {
MMKVError("check [%s] error: %zu size in total, file size is %zu", m_mmapID.c_str(), m_actualSize, fileSize);

checkLastConfirmedInfo();

if (!loadFromFile) {
auto strategic = onMMKVFileLengthError(m_mmapID);
if (strategic == OnErrorRecover) {
// make sure we don't over read the file
m_actualSize = fileSize - Fixed32Size;
loadFromFile = true;
needFullWriteback = true;
}
MMKVInfo("recover strategic for [%s] is %d", m_mmapID.c_str(), strategic);
}
}
}

size_t MMKV::readActualSize() {
MMKV_ASSERT(m_file->getMemory());
MMKV_ASSERT(m_metaFile->isFileValid());

uint32_t actualSize = 0;
memcpy(&actualSize, m_file->getMemory(), Fixed32Size);

if (m_metaInfo->m_version >= MMKVVersionActualSize) {
if (m_metaInfo->m_actualSize != actualSize) {
MMKVWarning("[%s] actual size %u, meta actual size %u", m_mmapID.c_str(), actualSize,
m_metaInfo->m_actualSize);
}
return m_metaInfo->m_actualSize;
} else {
return actualSize;
}
}

从 readActualSize 的实现中,我们似乎可以看出,m_file 似乎是有一个 header 结构,这个结构的第一个 int32 表示了文件的实际大小,那么m_actualSize < fileSize && (m_actualSize + Fixed32Size) <= fileSize这个判断其实表达的意思是除去这个 int32 之外的信息有没有被写入到文件。fileSize 就是 MemoryFile 中传入的 size 对齐 PAGE_SIZE 后的值

走到checkFileCRCValid逻辑,可以看出这里实际上是使用了 CRC 对存储内容进行了校验, 如果校验成功,则置 loadFromFile=true

我们初始化时不需要数据写回,因此调用 checkDataValid 返回后loadFromFile=true, needFullWriteback=false
走到 else 分支

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
SCOPED_LOCK(m_exclusiveProcessLock);

m_output = new CodedOutputData(ptr + Fixed32Size, m_file->getFileSize() - Fixed32Size);
if (m_actualSize > 0) {
writeActualSize(0, 0, nullptr, IncreaseSequence);
sync(MMKV_SYNC);
} else {
writeActualSize(0, 0, nullptr, KeepSequence);
}

// 初始化阶段increaseSequence=false,size=0, crcDigest=0, iv=nullptr
bool MMKV::writeActualSize(size_t size, uint32_t crcDigest, const void *iv, bool increaseSequence) {
// backward compatibility
oldStyleWriteActualSize(size);

if (!m_metaFile->isFileValid()) {
return false;
}

bool needsFullWrite = false;
m_actualSize = size;
m_metaInfo->m_actualSize = static_cast<uint32_t>(size);
m_crcDigest = crcDigest;
m_metaInfo->m_crcDigest = crcDigest;
if (m_metaInfo->m_version < MMKVVersionSequence) {
// 初始化不会走到这里
m_metaInfo->m_version = MMKVVersionSequence;
needsFullWrite = true;
}
if (unlikely(iv)) {
// 初始化时走这个分支,m_version改为MMKVVersionRandomIV
memcpy(m_metaInfo->m_vector, iv, sizeof(m_metaInfo->m_vector));
if (m_metaInfo->m_version < MMKVVersionRandomIV) {
m_metaInfo->m_version = MMKVVersionRandomIV;
}
needsFullWrite = true;
}
if (unlikely(increaseSequence)) {
// 初始化时走这个分支,m_version改为MMKVVersionActualSize
m_metaInfo->m_sequence++;
m_metaInfo->m_lastConfirmedMetaInfo.lastActualSize = static_cast<uint32_t>(size);
m_metaInfo->m_lastConfirmedMetaInfo.lastCRCDigest = crcDigest;
if (m_metaInfo->m_version < MMKVVersionActualSize) {
m_metaInfo->m_version = MMKVVersionActualSize;
}
needsFullWrite = true;
}
// iOS spec code ...
if (unlikely(needsFullWrite)) {
m_metaInfo->write(m_metaFile->getMemory());
} else {
m_metaInfo->writeCRCAndActualSizeOnly(m_metaFile->getMemory());
}
return true;
}

writeActualSize 在初始化阶段做了几件事情

  1. m_metaInfo->m_actualSize=0
  2. m_metaInfo->m_crcDigest=0
  3. m_metaInfo->m_version=MMKVVersionActualSize
  4. m_metaInfo->m_sequence++;
  5. m_metaInfo->m_lastConfirmedMetaInfo.lastActualSize = static_cast<uint32_t>(0);
  6. m_metaInfo->m_lastConfirmedMetaInfo.lastCRCDigest = 0;

将上述信息的修改全部写入 m_metaFile 持有的指针中,同时利用 MemoryFile 中这个指针是由 mmap 创建的特性,同步这些信息到文件描述符中

至此,MMKV 的初始化过程分析完了,总结一下

  1. m_mmapID相当于是 MMKV 自己的唯一 id,用于全局变量g_instanceDic进行内存管理
  2. m_path表达了 MMKV 对应的文件路径,用于创建对象 m_file
  3. m_file描述了 MMKV 对应的文件,这个文件可以是 IO 的,也可以是匿名内存的
  4. m_crcPath表达了 m_path 这个路径对应的 CRC 校验文件路径,用于创建对象 m_metaFile
  5. m_metaFile创建了 m_crcPath 对应的文件
  6. m_metaInfo是一个元数据结构体,相当于是m_metaFile的一份缓存

m_filem_metaFile均为MemoryFile对象,这个对象封装了文件描述符,同时构造了一个基于此文件描述符的 mmap 后的内存指针MemoryFile#m_ptr, 通过对这个指针的读写,我们可以快速得到 IO 中的内容

初始化阶段,构造函数构造了上述变量的同时,将m_actualSizem_crcDigest清 0,同时m_version=MMKVVersionActualSize

先写这么多,下一篇文章来分析一下 MMKV 的读写是如何实现的

ROOM数据库分析

以 2.1.0 版本源码简单分析下 ROOM,主要针对一些自己的困惑做一些记录

为了本文行文方便, 本文的分析全部基于以下的简单的 DB 构造

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Database(entities = {Config.class}, version = 0)
public abstract class AppDatabase extends RoomDatabase {}

@Entity(tableName = "config")
class Config @Ignore constructor(@ColumnInfo(name = "name") var name: String?, @ColumnInfo(name = "value") var value: String?) {
constructor() : this("", "")

@PrimaryKey(autoGenerate = true)
var id: Int = 0

}

@Dao
abstract class ConfigDao {

@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun insert(config: Config)
}

ROOM 的线程安全是如何保证的

先验知识: Transaction 和 Rollback Journals

sqlite 支持通过 transaction 模式来提交事务,一个典型的调用模式如下

1
2
3
4
5
6
7
db.beginTransaction();
try {
...
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}

可能这里就会有 Error 出现,那么就有可能会 skipdb.setTransactionSuccessful(), 而直接调用db.endTransaction(),根据 Android 文档说明

The changes will be rolled back if any transaction is ended without being marked as clean (by calling setTransactionSuccessful). Otherwise they will be committed.

也就是说 setTransactionSuccessful()未调用则执行 rollback,这里的 rollback 即 sqlite 的 rollback 策略,需要了解下 sqlite 的 rollback 策略

  1. 回滚日志档 (Rollback Journal):
    先将源文件备份至 Rollback Journal 中,再将要变动的内容直接写入 DB。当需要 rollback 时,再将原内容从 Rollback Journal 写回 DB;若要 commit 变更时,则只要将该档案刪除即可。
    而 rollback 的日志模式又可细分为 4 种:DELETE (SQLite 预设)、TRUNCATE (Android 版 SQLite 预设值)、PERSIST、MEMORY

TRUNCATE 这个模式就是 ROOM 在非 WAL 模式下的默认设置,即 Rollback Journal 总是清空,而非删除

这种模式下的文件目录为一个db文件+一个db-journal文件

  1. WAL (Write-Ahead Log):
    作法与 Rollback Journal 刚好相反。原内容仍保留在原 DB 之中,但新的变动则 append 至 WAL 文件。而当 COMMIT 发生时,仅代表某个 Transaction 已 append 进 WAL 文件了,但并不一定有写入原 DB (当 WAL 文件大小到达 checkpoint 的阈值时才会写入)。如此可让其他 DB 连接继续对原 DB 内容进行读取操作,而其他连接也可同时将变动 COMMIT 进 WAL 文件。

这种模式下的文件目录为一个db文件+一个db-shm文件(all SQLite database connections associated with the same database file need to share some memory that is used as an index for the WAL file)+一个db-wal文件

ROOM 对 API16 以上机型默认开启 WAL 模式

getDao 方法都是线程安全的

例如:

1
2
3
4
5
6
7
8
9
10
11
12
public ConfigDao getConfigDao() {
if (_configDao != null) {
return _configDao;
} else {
synchronized(this) {
if(_configDao == null) {
_configDao = new ConfigDao_Impl(this);
}
return _configDao;
}
}
}
  1. SQLiteOpenHelper#getWritableDatabase 也是线程安全的
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
public SQLiteDatabase getReadableDatabase() {
synchronized (this) {
return getDatabaseLocked(false);
}
}

@SuppressWarnings("unused")
private SQLiteDatabase getDatabaseLocked(boolean writable) {
if (mDatabase != null) {
if (!mDatabase.isOpen()) {
// Darn! The user closed the database by calling mDatabase.close().
mDatabase = null;
} else if (!writable || !mDatabase.isReadOnly()) {
// The database is already open for business.
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 {
int connectionPoolSize = mForcedSingleConnection ? 1 : 0;
try {
if (DEBUG_STRICT_READONLY && !writable) {
final String path = mContext.getDatabasePath(mName).getPath();
db = SQLiteDatabase.openDatabase(path, mPassword, mCipher, mFactory,
SQLiteDatabase.OPEN_READONLY, mErrorHandler, connectionPoolSize);
} else {
mNeedMode = true;
mMode = mEnableWriteAheadLogging ? Context.MODE_ENABLE_WRITE_AHEAD_LOGGING : 0;
db = Context.openOrCreateDatabase(mContext, mName, mPassword, mCipher,
mMode, mFactory, mErrorHandler, connectionPoolSize);
}
} catch (SQLiteException ex) {
if (writable) {
throw ex;
}
Log.e(TAG, "Couldn't open " + mName
+ " for writing (will try read-only):", ex);
final String path = mContext.getDatabasePath(mName).getPath();
db = SQLiteDatabase.openDatabase(path, mPassword, mCipher, mFactory,
SQLiteDatabase.OPEN_READONLY, mErrorHandler);
}
}

return getDatabaseLockedLast(db);

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

private SQLiteDatabase getDatabaseLockedLast(SQLiteDatabase db) {
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) {
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;
}

ROOM 的 Dao 层是依赖 Transaction 来执行数据库操作的

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

private final RoomDatabase __db;

@Override
public void insert(final Config arg0) {
__db.assertNotSuspendingTransaction();
__db.beginTransaction();
try {
__insertionAdapterOfConfig.insert(arg0); // 实际上就是SupportSQLiteStatement的操作,这里略去实现代码
__db.setTransactionSuccessful();
} finally {
__db.endTransaction();
}
}

因此,当我们通过 RoomDatabase#getXXXDao()执行数据库操作时,不同线程将会获得同一个 dao 对象,每个 dao 对象持有同一个 RoomDatabase 对象。那么来看看 RoomDatabase#beginTransaction()

1
2
3
4
5
6
7
@Deprecated
public void beginTransaction() {
assertNotMainThread();
SupportSQLiteDatabase database = mOpenHelper.getWritableDatabase();
mInvalidationTracker.syncTriggers(database);
database.beginTransaction();
}
  1. beginTransaction 委托了给其 SQLiteOpenHelper#getWritableDatabase()

参考上文中 SQLiteOpenHelper#getWritableDatabase()部分代码, getWritableDatabase()也是线程安全

  1. 在 SupportSQLiteDatabase#beginTransaction()前,进行了 InvalidationTracer#syncTriggers()调用
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
void syncTriggers(SupportSQLiteDatabase database) {
if (database.inTransaction()) {
// we won't run this inside another transaction.
return;
}
try {
// This method runs in a while loop because while changes are synced to db, another
// runnable may be skipped. If we cause it to skip, we need to do its work.
while (true) {
Lock closeLock = mDatabase.getCloseLock();
closeLock.lock();
try {
// there is a potential race condition where another mSyncTriggers runnable
// can start running right after we get the tables list to sync.
final int[] tablesToSync = mObservedTableTracker.getTablesToSync();
if (tablesToSync == null) {
return;
}
final int limit = tablesToSync.length;
database.beginTransaction();
try {
for (int tableId = 0; tableId < limit; tableId++) {
switch (tablesToSync[tableId]) {
case ObservedTableTracker.ADD:
startTrackingTable(database, tableId);
break;
case ObservedTableTracker.REMOVE:
stopTrackingTable(database, tableId);
break;
}
}
database.setTransactionSuccessful();
} finally {
database.endTransaction();
}
mObservedTableTracker.onSyncCompleted();
} finally {
closeLock.unlock();
}
}
} catch (IllegalStateException | SQLiteException exception) {
// may happen if db is closed. just log.
Log.e(Room.LOG_TAG, "Cannot run invalidation tracker. Is the db closed?",
exception);
}
}

这个 InvalidationTracer 涉及的模块比较大,主要是 ROOM 通过 sqlite 的 trigger 实现对数据变化的监听,下一个部分仔细展开.

InvalidationTracer 模块的分析

先验知识: Temporary Databases

sqlite 在创建表的时候可以用 temp 对 table 进行修饰为 Temporary Database,根据文档的描述https://www.sqlite.org/inmemorydb.html

A different temporary file is created each time, so that just like as with the special ":memory:" string, two database connections to temporary databases each have their own private database. Temporary databases are automatically deleted when the connection that created them closes.

简单来说,Temporary 是针对每个数据库链接,在其链接的生命周期下存在的数据表,链接关闭时,这个数据库也就不存在了。

先验知识: sqlite trigger

基本语法

  • CREATE TRIGGER — command that says that we want to create trigger.
    trigger-name — is a name of the trigger
  • BEFORE/AFTER/INSTEAD OF — is a mode of the trigger (when we’d like our operation to work — before our actual query, after or instead)
  • DELETE/INSERT/UPDATE ON table-name — is description on the query which will activate our trigger
  • BEGIN stmt; END — is actual trigger operation
1
2
3
4
CREATE TRIGGER update_value INSTEAD OF UPDATE ON persons
BEGIN
UPDATE persons(age) values(21)
END;

上面这个例子中,如果 persons 表被更新,那么 update_value 这个 trigger 就会触发, 将 persons 插入的 age 改为 21

Android 中如何使用 Trigger

我们知道 SQLiteDatabase 是可以编写 sql 语句的,因此我们可以尝试在 onCreate 阶执行我们的 trigger 构建语句即可

1
2
3
4
5
6
7
8
db.execSQL(
"""
create trigger order_added after insert on data
begin
insert into log(timestamp, payload) values(datetime(), new.order_id || ' ' || new.timestamp || ' ' || new.price);
end;
""".trimIndent()
)

InvalidationTracer, room_table_modification_log 表

首先我们看下 InvalidationTracer 的构造调用

AppDatabase_Impl.java

1
2
3
4
5
6
@Override
protected InvalidationTracker createInvalidationTracker() {
final HashMap<String, String> _shadowTablesMap = new HashMap<String, String>(0);
HashMap<String, Set<String>> _viewTables = new HashMap<String, Set<String>>(0);
return new InvalidationTracker(this, _shadowTablesMap, _viewTables, "config");
}

config 字段就是我们在 AppDatabase 中注册的 Entity 对应的 tableName

InvalidationTracker.java

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
@SuppressWarnings("WeakerAccess")
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
public InvalidationTracker(RoomDatabase database, Map<String, String> shadowTablesMap,
Map<String, Set<String>> viewTables, String... tableNames) {
mDatabase = database;
mObservedTableTracker = new ObservedTableTracker(tableNames.length);
mTableIdLookup = new ArrayMap<>();
mViewTables = viewTables;
mInvalidationLiveDataContainer = new InvalidationLiveDataContainer(mDatabase);
final int size = tableNames.length;
mTableNames = new String[size];
for (int id = 0; id < size; id++) {
final String tableName = tableNames[id].toLowerCase(Locale.US);
mTableIdLookup.put(tableName, id);
String shadowTableName = shadowTablesMap.get(tableNames[id]);
if (shadowTableName != null) {
mTableNames[id] = shadowTableName.toLowerCase(Locale.US);
} else {
mTableNames[id] = tableName;
}
}
// Adjust table id lookup for those tables whose shadow table is another already mapped
// table (e.g. external content fts tables).
for (Map.Entry<String, String> shadowTableEntry : shadowTablesMap.entrySet()) {
String shadowTableName = shadowTableEntry.getValue().toLowerCase(Locale.US);
if (mTableIdLookup.containsKey(shadowTableName)) {
String tableName = shadowTableEntry.getKey().toLowerCase(Locale.US);
mTableIdLookup.put(tableName, mTableIdLookup.get(shadowTableName));
}
}
}

这里有几个数据结构需要注意

  1. mTableIdLookup 记录了 tableName 和 index 的关系。例如这里”config”->0
  2. mTableNames 数组依次记录了 tableName。例如这里, mTableNames.length = 1, mTableNames[0] = “config”
  3. mDatabase 即 AppDatabase_Impl 对象
  4. mObservedTableTracker 是一个 ObservedTableTracker 对象,这个对象内部维护了三个数组:
    1. mTableObservers, 一个 long 数组
    2. mTriggerStates, 一个 boolean 数组
    3. mTriggerStateChanges, 一个 int 数组

再来关注下InvalidationTracker#internalInit的调用链,查阅源码可以得到

SupportSQLiteOpenHelper.Callback#onOpen->AppDatabase_Impl#internalInitInvalidationTracker->
InvalidationTracker#internalInit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void internalInit(SupportSQLiteDatabase database) {
synchronized (this) {
if (mInitialized) {
Log.e(Room.LOG_TAG, "Invalidation tracker is initialized twice :/.");
return;
}

// These actions are not in a transaction because temp_store is not allowed to be
// performed on a transaction, and recursive_triggers is not affected by transactions.
database.execSQL("PRAGMA temp_store = MEMORY;");
database.execSQL("PRAGMA recursive_triggers='ON';");
database.execSQL(CREATE_TRACKING_TABLE_SQL);
syncTriggers(database);
mCleanupStatement = database.compileStatement(RESET_UPDATED_TABLES_SQL);
mInitialized = true;
}
}

我们把 sql 语句串联一下

1
2
3
4
PRAGMA temp_store = MEMORY;
PRAGMA recursive_triggers='ON';
CREATE TEMP TABLE room_table_modification_log (table_id INTEGER PRIMARY KEY, invalidated INTEGER NOT NULL DEFAULT 0);
UPDATE room_table_modification_log SET invalidated 0 WHERE invalidated = 1;
  1. 创建了一个 In-memory 的临时表 room_table_modification_log
  2. 主键 table_id, 我们这里可以猜测这个 table_id 应该就和构造函数里提到的 id 概念对应
  3. invalidated 字段,字面意思应该是表示数据是否有效

在 CREATE 语句和 UPDATE 语句之间,还有一个 syncTriggers 调用

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
void syncTriggers(SupportSQLiteDatabase database) {
if (database.inTransaction()) {
// we won't run this inside another transaction.
return;
}
try {
// This method runs in a while loop because while changes are synced to db, another
// runnable may be skipped. If we cause it to skip, we need to do its work.
while (true) {
Lock closeLock = mDatabase.getCloseLock();
closeLock.lock();
try {
// there is a potential race condition where another mSyncTriggers runnable
// can start running right after we get the tables list to sync.
final int[] tablesToSync = mObservedTableTracker.getTablesToSync(); // #1
if (tablesToSync == null) {
return;
}
final int limit = tablesToSync.length;
database.beginTransaction();
try {
for (int tableId = 0; tableId < limit; tableId++) {
switch (tablesToSync[tableId]) {
case ObservedTableTracker.ADD:
startTrackingTable(database, tableId);
break;
case ObservedTableTracker.REMOVE:
stopTrackingTable(database, tableId);
break;
}
}
database.setTransactionSuccessful();
} finally {
database.endTransaction();
}
mObservedTableTracker.onSyncCompleted();
} finally {
closeLock.unlock();
}
}
} catch (IllegalStateException | SQLiteException exception) {
// may happen if db is closed. just log.
Log.e(Room.LOG_TAG, "Cannot run invalidation tracker. Is the db closed?",
exception);
}
}

接着看下#1 这里, ObservedTableTracker#getTablesToSync()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Nullable
int[] getTablesToSync() {
synchronized (this) {
if (!mNeedsSync || mPendingSync) {
return null;
}
final int tableCount = mTableObservers.length;
for (int i = 0; i < tableCount; i++) {
final boolean newState = mTableObservers[i] > 0;
if (newState != mTriggerStates[i]) {
mTriggerStateChanges[i] = newState ? ADD : REMOVE;
} else {
mTriggerStateChanges[i] = NO_OP;
}
mTriggerStates[i] = newState;
}
mPendingSync = true;
mNeedsSync = false;
return mTriggerStateChanges;
}
}

如果不需要同步(!mNeedSync)或者正在同步(mPendingSync),返回 null

否则

我们对每个 table_id 做判断,在当前场景下, mTableObservers[0] = 0, mTriggerStates[0] = false, mTriggerStateChanges[0] = 0, 经过算法后
mTableObservers[0] = 0, mTriggerStates[0] = false, mTriggerStateChanges[0] = NO_OP

回到 syncTriggers 这里,返回了一个 NO_OP,那么什么事情都不做。直接返回

那么时候才会走到 startTrackingTable 或者 stopTrackingTable 呢?针对这个问题,我们看下 ObservedTableTracker.ADD 的赋值位置:

1
2
3
4
5
6
final boolean newState = mTableObservers[i] > 0;
if (newState != mTriggerStates[i]) {
mTriggerStateChanges[i] = newState ? ADD : REMOVE;
} else {
mTriggerStateChanges[i] = NO_OP;
}

可以看到 newState = mTableObservers[i] > 0 成立时才会是 ADD, 那么我们看看什么时候 mTableObservers[i] > 0,可以发现是ObservedTableTracker#onAdded调用

onAdded 调用链:

InvalidationTracker#addObserver->InvalidationTracker#onAdded()

InvalidationTracker#addWeakObserver->InvalidationTracker#addObserver->InvalidationTracker#onAdded()

addWeakObserver 则用在了RoomTrackingLiveData#mRefreshRunnableLimitOffsetDataSource()

这里我们可以大致做一个推断

  1. paging 或 ROOM 中对 LiveData 的支持,依赖了 addObserver 或者 addWeakObserver 来实现,这是必然的。因为二者是一个典型的观察者模式范型的表现

接下来我们看看 addObserver 的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@SuppressLint("RestrictedApi")
@WorkerThread
public void addObserver(@NonNull Observer observer) {
final String[] tableNames = resolveViews(observer.mTables);
int[] tableIds = new int[tableNames.length];
final int size = tableNames.length;

for (int i = 0; i < size; i++) {
Integer tableId = mTableIdLookup.get(tableNames[i].toLowerCase(Locale.US));
if (tableId == null) {
throw new IllegalArgumentException("There is no table with name " + tableNames[i]);
}
tableIds[i] = tableId;
}
ObserverWrapper wrapper = new ObserverWrapper(observer, tableIds, tableNames);
ObserverWrapper currentObserver;
synchronized (mObserverMap) {
currentObserver = mObserverMap.putIfAbsent(observer, wrapper);
}
if (currentObserver == null && mObservedTableTracker.onAdded(tableIds)) {
syncTriggers();
}
}

observer.mTables 就是 observer 所观察的 tableName 数组, 是 String[], 我们这里可以假设是[“config”], resolveViews 主要解决了数据库的视图映射,这里我们不涉及。可以直接理解 tableNames=[“config”], 后续算法我们可以得到 tablesIds.length = 1, tablesIds[0] = 0. 之后做了两件事情

  1. 将一个 ObserverWrapper 对象放到 mObserverMap 中,map 维护了一个 Observer 和 ObserverWrapper 关系
  2. 如果是第一次加入 observerMap 的 observer 对象,调用 onAdded()
  3. 如果 2 的基础上,onAdded()返回 true,调用 syncTriggers()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
boolean onAdded(int... tableIds) {
boolean needTriggerSync = false;
synchronized (this) {
for (int tableId : tableIds) {
final long prevObserverCount = mTableObservers[tableId];
mTableObservers[tableId] = prevObserverCount + 1;
if (prevObserverCount == 0) {
mNeedsSync = true;
needTriggerSync = true;
}
}
}
return needTriggerSync;
}

这里对每个 tableId 做了判断, 如果其之前从没有过 observer 对该 tableId 进行监听, 那么就需要进行一次 sync(needTriggerSync = true)

回忆一下之前对 getTablesToSync 的分析,此时 mTableObservers[tableId]>0, 那么在 syncTriggers()逻辑中就会得到一个 ADD 指令, 我们看看 ADD 对应的startTrackingTable函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private void startTrackingTable(SupportSQLiteDatabase writableDb, int tableId) {
writableDb.execSQL(
"INSERT OR IGNORE INTO " + UPDATE_TABLE_NAME + " VALUES(" + tableId + ", 0)");
final String tableName = mTableNames[tableId];
StringBuilder stringBuilder = new StringBuilder();
for (String trigger : TRIGGERS) {
stringBuilder.setLength(0);
stringBuilder.append("CREATE TEMP TRIGGER IF NOT EXISTS ");
appendTriggerName(stringBuilder, tableName, trigger);
stringBuilder.append(" AFTER ")
.append(trigger)
.append(" ON `")
.append(tableName)
.append("` BEGIN UPDATE ")
.append(UPDATE_TABLE_NAME)
.append(" SET ").append(INVALIDATED_COLUMN_NAME).append(" = 1")
.append(" WHERE ").append(TABLE_ID_COLUMN_NAME).append(" = ").append(tableId)
.append(" AND ").append(INVALIDATED_COLUMN_NAME).append(" = 0")
.append("; END");
writableDb.execSQL(stringBuilder.toString());
}
}

我们尝试把所有 sql 展开得到

1
2
3
4
5
6
7
INSERT OR IGNORE INTO room_table_modification_log VALUES($tableId, 0)

CREATE TEMP TRIGGER IF NOT EXISTS `room_table_modification_trigger_$tableName_UPDATE` AFTER UPDATE ON `$tableName` BEGIN UPDATE room_table_modification_log SET invalidated = 1 WHERE tableId = $tableId AND invalidated = 0; END

CREATE TEMP TRIGGER IF NOT EXISTS `room_table_modification_trigger_$tableName_REMOVE` AFTER REMOVE ON `$tableName` BEGIN UPDATE room_table_modification_log SET invalidated = 1 WHERE tableId = $tableId AND invalidated = 0; END

CREATE TEMP TRIGGER IF NOT EXISTS `room_table_modification_trigger_$tableName_INSERT` AFTER INSERT ON `$tableName` BEGIN UPDATE room_table_modification_log SET invalidated = 1 WHERE tableId = $tableId AND invalidated = 0; END

这一段就是在 DB,建立了对不同的表的有效值 trigger 机制, 通过这个 trigger 的设置,我们能进一步理解 room_table_modification_log 的作用

表名 作用
tableId 唯一值,与上层 Entity 中的 tableName 构成一一对应
invalidated 0 或 1,1 表示数据有更新

那么综合一下上述分析:

  1. 当且仅当一个表首次被监听时, 我们会创建一组 trigger,对这个表的数据更新状态通过 invalidated 进行表示
  2. 在 Java 层,我们使用一个 Map 保存了 Observer 的映射关系,以便后续观察到数据变化时唤起

看完了监听的建立,我们就要来看看如何唤起这个监听了

InvalidationTracker 观察者模式的触发

首先需要明确的是,我们肯定是要获得 DB 中哪些表的数据进行了更新,根据上述分析。我们可以从 room_table_modification_log 中 invalidated=1 的项进行分析。那么遵循这个思路,我们查阅代码中对 room_table_modification_log 的查询操作

InvalidationTracker#mRefreshRunnable

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
Runnable mRefreshRunnable = new Runnable() {
@Override
public void run() {
final Lock closeLock = mDatabase.getCloseLock();
Set<Integer> invalidatedTableIds = null;
try {
closeLock.lock();

if (!ensureInitialization()) {
return;
}

if (!mPendingRefresh.compareAndSet(true, false)) {
// no pending refresh
// 防止重入
return;
}

if (mDatabase.inTransaction()) {
// current thread is in a transaction. when it ends, it will invoke
// refreshRunnable again. mPendingRefresh is left as false on purpose
// so that the last transaction can flip it on again.
return;
}

if (mDatabase.mWriteAheadLoggingEnabled) { // #1
// This transaction has to be on the underlying DB rather than the RoomDatabase
// in order to avoid a recursive loop after endTransaction.
SupportSQLiteDatabase db = mDatabase.getOpenHelper().getWritableDatabase();
db.beginTransaction();
try {
invalidatedTableIds = checkUpdatedTable();
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
} else {
invalidatedTableIds = checkUpdatedTable();
}
} catch (IllegalStateException | SQLiteException exception) {
// may happen if db is closed. just log.
Log.e(Room.LOG_TAG, "Cannot run invalidation tracker. Is the db closed?",
exception);
} finally {
closeLock.unlock();
}
if (invalidatedTableIds != null && !invalidatedTableIds.isEmpty()) {
synchronized (mObserverMap) {
for (Map.Entry<Observer, ObserverWrapper> entry : mObserverMap) {
entry.getValue().notifyByTableInvalidStatus(invalidatedTableIds); // #2
}
}
}
}

private Set<Integer> checkUpdatedTable() {
ArraySet<Integer> invalidatedTableIds = new ArraySet<>();
Cursor cursor = mDatabase.query(new SimpleSQLiteQuery(SELECT_UPDATED_TABLES_SQL)); // #3
//noinspection TryFinallyCanBeTryWithResources

// 通过cursor读到 invalidatedTableIds,略去
if (!invalidatedTableIds.isEmpty()) {
mCleanupStatement.executeUpdateDelete(); // 有数据更新,重置下invalidated标志位
}
return invalidatedTableIds;
}
};

通过 checkUpdatedTable 函数,我们得到了所有 invalidated=1 的 tableId,之后在 mObserverMap 中通知观察者 notifyByTableInvalidStatus

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void notifyByTableInvalidStatus(Set<Integer> invalidatedTablesIds) {
Set<String> invalidatedTables = null;
final int size = mTableIds.length;
for (int index = 0; index < size; index++) {
final int tableId = mTableIds[index];
if (invalidatedTablesIds.contains(tableId)) {
if (size == 1) {
// Optimization for a single-table observer
invalidatedTables = mSingleTableSet;
} else {
if (invalidatedTables == null) {
invalidatedTables = new ArraySet<>(size);
}
invalidatedTables.add(mTableNames[index]);
}
}
}
if (invalidatedTables != null) {
mObserver.onInvalidated(invalidatedTables);
}
}

invalidatedTables 记录了 tableId 对应的原 Entity 对应的 table_name, onInvalidated 则是接口,由 Observer 自己实现,这里我们以 RoomTrackingLiveData 分析一下,找到其内部聚合的onInvalidated实现

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

@SuppressLint({"RestrictedApi"})
RoomTrackingLiveData(RoomDatabase database, InvalidationLiveDataContainer container, boolean inTransaction, Callable<T> computeFunction, String[] tableNames) {
// ...
this.mObserver = new Observer(tableNames) {
public void onInvalidated(@NonNull Set<String> tables) {
ArchTaskExecutor.getInstance().executeOnMainThread(RoomTrackingLiveData.this.mInvalidationRunnable); // #1
}
};
}

final Runnable mInvalidationRunnable = new Runnable() {
@MainThread
public void run() {
boolean isActive = RoomTrackingLiveData.this.hasActiveObservers();
if (RoomTrackingLiveData.this.mInvalid.compareAndSet(false, true) && isActive) {
RoomTrackingLiveData.this.getQueryExecutor().execute(RoomTrackingLiveData.this.mRefreshRunnable); // #2
}

}
};

final Runnable mRefreshRunnable = new Runnable() {
@WorkerThread
public void run() {
if (RoomTrackingLiveData.this.mRegisteredObserver.compareAndSet(false, true)) {
RoomTrackingLiveData.this.mDatabase.getInvalidationTracker().addWeakObserver(RoomTrackingLiveData.this.mObserver);
}

boolean computed;
do {
computed = false;
if (RoomTrackingLiveData.this.mComputing.compareAndSet(false, true)) {
try {
Object value = null;

while(RoomTrackingLiveData.this.mInvalid.compareAndSet(true, false)) {
computed = true;

try {
value = RoomTrackingLiveData.this.mComputeFunction.call();
} catch (Exception var7) {
throw new RuntimeException("Exception while computing database live data.", var7);
}
}

if (computed) {
RoomTrackingLiveData.this.postValue(value); // #3
}
} finally {
RoomTrackingLiveData.this.mComputing.set(false);
}
}
} while(computed && RoomTrackingLiveData.this.mInvalid.get());

}
};

篇幅问题这里不分析 RoomTrackingLiveData 的仔细实现,从关键的#1, #2, #3 看,RoomTrackingLiveData 利用这个 Observer,当Observer#onInvalidated被触发的时候, 对 DB 进行了mComputeFunction#call()操作,这个我们可以直接猜测就是上层的 DB 查询具体实现,这个由具体的 DAO 层解释。然后调用 postValue 来刷新 LiveData 的值

分析完整个监听被触发的过程,有一个问题没有解释,就是InvalidationTracker#mRefreshRunnable是如何被调用的

追溯源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@SuppressWarnings("WeakerAccess")
public void refreshVersionsAsync() {
// TODO we should consider doing this sync instead of async.
if (mPendingRefresh.compareAndSet(false, true)) {
mDatabase.getQueryExecutor().execute(mRefreshRunnable);
}
}

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
@WorkerThread
public void refreshVersionsSync() {
syncTriggers();
mRefreshRunnable.run();
}

追溯调用链

  1. RoomDatabase#endTransaction()->InvalidationTracker#refreshVersionsAsync()

  2. LimitOffsetDataSource#isInvalid()->InvalidationTracker#refreshVersionsSync()

篇幅问题,不分析跟 Paging Library 关联大的 2。分析一下 1

实际上,在[ROOM 的线程安全是如何保证的]这节的第三部分,我们已经能发现 Dao 层的 DB 操作都是依赖 beginTransaction, endTransaction 来完成 DB 事务的。也就是说,只要 DAO 层有 DB 事务发生,那么 ROOM 必定会在 getQueryExecutor()的线程池中,执行 mRefreshRunnable, 如果发现了有数据更新的 table,就将这些 table 信息全部抛给Observer#onInvalidated处理

InvalidationTracker 小结

InvalidationTracker的职责即建立了业务对 DB 数据写的观察者模式, 业务的 Observer 被InvalidationTracker聚合持有,同时建立一个临时表room_table_modification_log

表名 作用
tableId 唯一值,与上层 Entity 中的 tableName 构成一一对应
invalidated 0 或 1,1 表示数据有更新

当某个表被业务监听时,对这个表创建 trigger 建立观察者模式,监听这张表的写操作,trigger 被触发时就写room_table_modification_log

DAO 层业务代码委托RoomDatabase通过beginTransaction, endTransaction来完成 DB 的事务提交,这些函数被调用时,顺带触发了InvalidationTracker内部对表room_table_modification_log的异步查询,查询到有数据更新(invalidated=1)的表名信息, 将这些表名信息带给Observer#onInvalidated的参数中。例如 RoomTrackingLiveData 中, 利用InvalidationTracker建立的这套机制,实现了 DAO 层 LiveData 的持有数据实时更新的特性

Android focus概念

Android focus 相关概念

focus

焦点实际上是窗口系统中一个重要概念,如果一个窗体获得了焦点,其含义是该窗体获得了可交互的机会。在 android 中也是如此,例如一个 EditText,只有当其获得了焦点时,我们才能通过键盘对其做输入操作

focusable && focusableInTouchMode

这两个属性是比较容易造成理解混淆。

首先我们看下focusableInTouchMode的官方定义

Boolean that controls whether a view can take focus while in touch mode. If this is true for a view, that view can gain focus when clicked on, and can keep focus if another view is clicked on that doesn't have this attribute set to true.

翻译一下,如果设置为 true,那么当其被触摸点击时,就有能力获得焦点;并且其他为 false 的 View 的点击都不能夺取这个焦点。

举例来说,如果有一个 EditText 其focusableInTouchMode为 true, 那么我们在屏幕上对其点击就会拉起虚拟键盘。

另外,有些业务场景是点击 EditText 后不拉起键盘,而是弹起 Dialog 来输入的场景。那么这个时候最简单的方案就是设置focusableInTouchMode为 false,然后给这个 EditText 的点击事件实现你的弹窗逻辑

但这里就有个冲突,如果focusableInTouchMode为 true,那么此时我对 Widget 的点击到底算是点击(click)事件还是算作 focus 事件呢?答案是算作 focus 事件,所以有时候你会遇到给 Button 设置focusableInTouchMode为 true 后,每次第一次点击都不触发 onClick 的问题, 因为其 focus 的逻辑会优先 click 逻辑触发

事实上,任何窗体系统的交互其实是要分为两步走的:

  1. 找到特定的 Widget,让其获得焦点
  2. 开始交互(输入, 点击等)

因此获取焦点的逻辑应该都是优先触发的

这里有个疑问是为什么额外需要一个 touchMode,我的理解是跟 android 机器面临的历史问题,历史上 android 机器曾经是有软硬件两种操作方式的。有些古老手机上会保留类似滚球的操作设备来在不同窗体上 focus。另外,类似智能电视这样的设备上也会有硬件(遥控器), 触摸屏两种交互的方式.

而 focusable 则是表示一个 Widget 是否能被 focus,如果这个属性设置成 false,那么这个 Widget 是不能获得焦点的,用户也无法与这个 Widget 进行交互(例如输入)

同时根据文档描述

Setting this to false will also ensure that this view is not focusable in touch mode.

这个属性为 false 时,会将focusableInTouchMode也变成 false

在代码中,我们发现View#setFocusable有一个 int 作为参数,除去两个常见的FOCUSABLENOT_FOCUSABLE对应 xml 中的 true 和 false 外,我们看到其还有一个选项, FOCUSABLE_AUTO. 根据注释,这个是 Widget 的默认 focusable 值,android 系统会自动决定 Widget 的焦点

descendantFocusability 属性

这个属性常常用在 ViewGroup,用来定义这个 ViewGroup 和其子 View 之间获取焦点时的关系

1
2
3
4
5
6
7
8
9
10
11
12
+------------------------------------------------------------------------------------------+
| Constant Value Description |
+------------------------------------------------------------------------------------------+
| afterDescendants 1 The ViewGroup will get focus only if |
| none of its descendants want it. |
+------------------------------------------------------------------------------------------+
| beforeDescendants 0 The ViewGroup will get focus before |
| any of its descendants. |
+------------------------------------------------------------------------------------------+
| blocksDescendants 2 The ViewGroup will block its descendants from |
| receiving focus. |
+------------------------------------------------------------------------------------------+

也就是说,根据不同的定义,ViewGroup 有能力在子 View 之前,之后,或者阻止子 View 获取焦点

那么这个属性的使用场景是什么呢? 例如你要在一个 ListView 里面与 item 的某个元素做交互, 那么你是希望子 View 先于 ListView 交互的(这样才能避免 ListView 的焦点逻辑优先触发), 此时设置 afterDescendants 就是有用的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void onItemSelected(AdapterView<?> parent, View view,
int position, long id) {
ListView listView = getListView();
Log.d(TAG, "onItemSelected gave us " + view.toString());
Button b = (Button) view.findViewById(R.id.button);
EditText et = (EditText) view.findViewById(R.id.editor);
if (b != null || et != null) {
// Use afterDescendants to keep ListView from getting focus
listView.setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS);
if(et!=null) et.requestFocus();
else if(b!=null) b.requestFocus();
} else {
if (!listView.isFocused()) {
// Use beforeDescendants so that previous selections don't re-take focus
listView.setDescendantFocusability(ViewGroup.FOCUS_BEFORE_DESCENDANTS);
listView.requestFocus();
}
}

}

Android TextView绘制技巧

文本绘制概念

  • Top - The maximum distance above the baseline for the tallest glyph in the font at a given text size.
  • Ascent - The recommended distance above the baseline for singled spaced text.
  • Descent - The recommended distance below the baseline for singled spaced text.
  • Bottom - The maximum distance below the baseline for the lowest glyph in the font at a given text size.
  • Leading - The recommended additional space to add between lines of text.

这套概念在android.text.Layout中也成立

需要注意的是 android.text.Layout 也是从 0 计算的, 对于其 API getLineBottom, getLineBaseline, getLineTop…. 都是从 0 计算的

同时, android.text.TextView#getLayout()方法必定在 measure 过程结束后才能非空

TextView#Layout 奇招

TextView 的 Layout 属性管理了文本的布局展示,同时也提供了两个能力,

获取省略号在当前行的开始位置;
获取当前行内被省略文字的长度。

/** * Return the offset of the first character to be ellipsized away, * relative to the start of the line. (So 0 if the beginning of the * line is ellipsized, not getLineStart().) */
public abstract int getEllipsisStart(int line);

/** * Returns the number of characters to be ellipsized away, or 0 if * no ellipsis is to take place. */
public abstract int getEllipsisCount(int line);

有了以上能力,针对上文最后提到的问题马上便会有思路:通过精确计算被省略的文字位置,截取字符串重新插入占位标识符,然后实现在省略号处添加图片

1
2
3
4
5
6
7
8
String text = guessLikeBean.getTitle()+"(精)";
mTvTitle.setText(text);
int ellipsisCount = mTvTitle.getLayout().getEllipsisCount(mTvTitle.getLineCount() - 1);
if (ellipsisCount > 0) {
text = text.substring(0, text.length() - ellipsisCount - 1) + "…(精)";
}
SpannableString imageString = new SpannableString(text);
...

同时,为了保证 TextView#getLayout 一定有值,我们可以使用 View#post 函数来调用我们的函数,View#post 会确保 View 在 attach 之后再执行,而 attach 一定保证了 layout 过程先执行

实际上 TextView#Layout 中可以找到很多跟文本相关的属性,在排版的过程中或多或少你会用到,因此遇到文本排版问题时,不妨先试试这个, 例如:

Layout 的一个子类BoringLayout,有一个静态方法isBoring(),可以用来判断一段文字是否能在一行放下,这个方法就有广泛的应用场景

如何绘制居中的图标

参考https://gist.github.com/TedaLIEz/97f03a2cb842621f022bd27ba1dfd020

如何判断文字能否塞得下一行

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
/**
* titleTxt: 文本
* layout: TextView#Layout
* title: TextView
* maxWidth: 期望的view最大宽度
* spacing: 期望能够剩余出来的宽度, 即文本的最大宽度为maxWidth - spacing
**/
private fun badgeHelper(titleTxt: String, layout: Layout, title: TextView, maxWidth: Int, spacing: Int) : String {
var txt = titleTxt + "精精精"
val lineCount = layout.lineCount
if (lineCount == 1) {
Log.d(TAG, "we have only one line text $titleTxt, return")
return txt
}
val dWidth = spacing
val lastLineStart = layout.getLineStart(lineCount - 1)
val lastLineStr = txt.replace('.', 'x').substring(lastLineStart)
val boring = BoringLayout.isBoring(lastLineStr, title.paint)
if (boring != null) {
val sW = ScreenUtil.getScreenWidth(title.context)
val px = maxWidth
val width = boring.width
if (sW < px + width) {
// text ellipsize
Log.w(TAG, "BoringLayout says it will ellipsize, skip some text now")
val diff = px + width - sW
val diffSize = ((diff + dWidth) / title.textSize).roundToInt()
txt = titleTxt.substring(0, titleTxt.length - diffSize) + title.context.getString(R.string.ellipsize_string) + "(精)"
Log.d(TAG, "we change txt from $titleTxt to $txt")
return txt
}
}
val size = (dWidth / title.textSize).roundToInt()
val seq = TextUtils.ellipsize(lastLineStr, title.paint, (title.width - title.paddingRight - title.paddingLeft).toFloat(), TextUtils.TruncateAt.END)
val lastIndexOfDot = seq.lastIndexOf(title.context.getString(R.string.ellipsize_string))
if (lastIndexOfDot != -1 && lastLineStart + lastIndexOfDot - size - 3 > 0) {
Log.w(TAG, "TextUtils says it will ellipsize, skip some text now")
txt = titleTxt.substring(0, lastLineStart + lastIndexOfDot - size - 3) + title.context.getString(R.string.ellipsize_string) + "(精)"
Log.d(TAG, "we change txt from $titleTxt to $txt")
return txt
}

val ellipsisCount = layout.getEllipsisCount(lineCount - 1)
if (ellipsisCount > 0) {
Log.w(TAG, "Layout says it will ellipsize, skip some text now")
txt = txt.substring(0, txt.length - ellipsisCount - size - 3) + title.context.getString(R.string.ellipsize_string) + "(精)"
Log.d(TAG, "we change txt from $titleTxt to $txt")
return txt
}
return txt

}

References

ref: https://stackoverflow.com/a/27631737/4380801

ref: https://tristanzeng.github.io/2019/05/29/TextView%E5%A4%9A%E8%A1%8C%E6%96%87%E5%AD%97%E8%B6%85%E5%87%BA%E6%97%B6%E5%A6%82%E4%BD%95%E5%9C%A8%E7%9C%81%E7%95%A5%E5%8F%B7%E5%90%8E%E6%B7%BB%E5%8A%A0%E5%9B%BE%E6%A0%87/

Kotlin协程浅析

本文主要参考 KotlinConf 2017 内容来对 Kotlin 中协程的实现进行解析

TL;DR;

Kotlin的协程本质上仍旧是Callback式的调用, 但采用了CPS的编程范式,使得我们的代码显得线性

这里略过对协程的定义,首先我们需要解释 CPS 是什么东西

CPS()

CPS 实际上是一种编程风格,根据 wiki 定义

In functional programming, continuation-passing style (CPS) is a style of programming in which control is passed explicitly in the form of a continuation. (from Wikipedia.)

举个简单的例子,如果我们定义一类型的函数,这些函数都以 async 开头,并且在函数参数中都带有一个 callback 函数参数,每个函数在执行自己的逻辑完成后,都通过 callback 来处理结果

1
2
3
4
5
6

fun asyncDoSomething(param1: Int, param2: Any, callback: (Any?) -> Unit) {
// ...
// when execution is done
callback.invoke(result)
}

这种类型的函数中,callback 往往被称作 continuation,因为实际上它表达的是下一个函数执行的逻辑,即这个回调函数决定了程序接下来的行为

上述这种方式就是一种标准的 CPS 实现方式

举一个现实例子来说,例如:

1
2
3
4
5
fun postItem(item: Item) {
val token = requestToken()
val post = createPost(token, item)
processPost(post)
}

这里你有一个过程需要三个子过程来完成

对于 requestToken()函数来说,createPost 和 processPost 是其过程的 continuation

那么我们可以尝试把 postItem 这个过程转换为一个 CPS 式的编码范式

1
2
3
4
5
6
7
fun postItem(item: Item) {
requestToken { token ->
createPost(token, item) { post ->
processPost(post)
}
}
}

这种写法看上去还算 OK,但是也有很多问题,例如:

  1. Callback Hell
  2. 过分多的回调,会带来非常大的调用栈开销

那么 Kotlin 是如何实现 CPS 的呢?

Kotlin 对 CPS 的实现

Kotlin 对 CPS 风格的实现,实际上就是解决了 Kotlin 对协程的支持

1
suspend fun createPost(token: Token, item: Item) : Post { ... }

实际上会转换为:

1
2
3
4
5
6
7
Object createPost(Token token, Item item, Continuation<Post> cont) { … }

interface Continuation<in T> {
val context: CoroutineContext
fun resume(value: T)
fun resumeWithException(exception: Throwable)
}

可以很明显的发现 kotlin 的 Continuation 对象就是一个典型的 Callback

接下来我们来看看 Kotlin 对协程的实现方式

Kotlin 对协程的实现

1
2
3
4
5
suspend fun postItem(item: Item) {
val token = requestToken()
val post = createPost(token, item)
processPost(post)
}

上面是一段典型的 kotlin 协程代码,根据之前的讨论,对于协程,我们有一种典型的实现:

1
2
3
4
5
6
7
fun postItem(item: Item) {
requestToken { token ->
createPost(token, item) { post ->
processPost(post)
}
}
}

这种实现被认为存在回调地狱和栈开销的问题,接下来我们看看 kotlin 是如何解决的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fun postItem(item: Item, cont: Continuation) {
val sm = cont as? ThisSM ?: object : ThisSM {
fun resume(…) {
postItem(null, this)
}
}
switch (sm.label) {
case 0:
sm.item = item
sm.label = 1
requestToken(sm)
case 1:
val item = sm.item
val token = sm.result as Token
sm.label = 2
createPost(token, item, sm)

}

可以看到,这里 kotlin 实际上是利用状态机来实现 CPS 风格的,对于每一个 suspend point,都有一个唯一的状态值对应,状态机根据不同的状态,执行不同的过程

总结一下:

  1. 一个 suspend 函数定义了一个状态机
  2. 一个协程就是一个状态机的实例,这个实例就是将其调用的 suspend 函数组合在了一起

JSBridge解析

背景

利用 webview 的特性,我们设计一个 jsbridge 来实现 js 和 native 的双向通信

native 和 JS 的调用实现手段

JavaScript 调用 Native

  1. 注入 api
    这种方式的实现要么通过 webview 的借口,向 js 的 window 对象注入能力,js 调用这些能力时直接执行对应的 native 逻辑
    要么就是在我们实现浏览器的 jsbinding 时,就预埋一些基础能力(类似系统级别的函数接口)

  2. 拦截 URL SCHEME

    这种实现方式主要依赖 native 的拦截能力,让 js 向一个 native 约定的 scheme 发送请求(例如使用 iframe.src)
    这种实现的缺陷一是会有 url 长度的隐患,另外创建请求会有耗时

Native 调用 JS

相对简单,使用webview的evaluateJS类似的接口就可以调用

JSBridge 实现方式

本质上讲,JSBridge 可以当作是一种 RPC,其核心实现就是句柄解析调用,JSBridge 的句柄往往就是一个 BridgeName,同时携带约定数据格式的序列化数据

JS 调用 native 的 RPC 实现是比较简单的,大致可以表达为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
window.JSBridge = {
// 调用 Native
invoke: function(bridgeName, data) {
// 判断环境,获取不同的 nativeBridge
nativeBridge.postMessage({
bridgeName: bridgeName,
data: data || {}
});
},
receiveMessage: function(msg) {
var bridgeName = msg.bridgeName,
data = msg.data || {};
// 具体逻辑
}
};

这里有个问题,目前的调用但是单向的,如何在一次调用实现双向通信呢?这里实际上可以类比 JSONP 机制进行处理

JSONP请求发送时,url参数里有callback参数,其值是当前唯一,且被当前window记录,随后服务器返回的记录中,也会用这个参数值为句柄,调用相应的回调函数

因此我们可以参考这个设计,在我们的 jsbridge 设计一个 id 的生成器,和一个 id 保持一对一映射关系的 map,每次 js 调用 native 时,将这个 id 一并带给 native,native 不做理解,透传这个 id 回到 js 端,js 可以利用这个 id 在 map 中寻找到对应的 callback,调用对应的回调。用代码描述如下:

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
(function () {
var id = 0,
callbacks = {};

window.JSBridge = {
// 调用 Native
invoke: function(bridgeName, callback, data) {
// 判断环境,获取不同的 nativeBridge
var thisId = id ++; // 获取唯一 id
callbacks[thisId] = callback; // 存储 Callback
nativeBridge.postMessage({
bridgeName: bridgeName,
data: data || {},
callbackId: thisId // 传到 Native 端
});
},
receiveMessage: function(msg) {
var bridgeName = msg.bridgeName,
data = msg.data || {},
callbackId = msg.callbackId; // Native 将 callbackId 原封不动传回
// 具体逻辑
// bridgeName 和 callbackId 不会同时存在
if (callbackId) {
if (callbacks[callbackId]) { // 找到相应句柄
callbacks[callbackId](msg.data); // 执行调用
}
} elseif (bridgeName) {

}
}
};
})();

P.S jsbridge 如何解决加载顺序问题?

根据我们之前的分析,jsbridge 的建立应该在业务的 js 执行前调用,那么这里就会有一个强逻辑的假设,我们有没有办法解除这种依赖关系呢?
jsbridge 的解决方案是业务的 js 将自己将要调用的 bridge 调用通过闭包的形式传到 window 的一个固定对象中,然后我们在加载 jsbridge 时,从这个约定的固定对象中将所有的调用闭包取出,然后依次调用即可

因为这层逻辑的存在,即使你的 jsbridge 因为网页的 CSP 策略导致加载 bridge 的请求被屏蔽,但由于屏蔽的请求仍旧会触发终端 native 的拦截器代码,你仍旧可以加载 jsbridge,同时利用闭包特性,将请求依次调用回来

Kotlin协程实战

学习路径

  1. https://codelabs.developers.google.com/codelabs/kotlin-coroutines/#0
  2. 官方文档
  3. https://kaixue.io/kotlin-coroutines-1/

下面说下个人理解

协程尝试解决什么问题

从编码角度,协程尝试解决了回调地狱的问题
从实现上,协程尝试减少了线程切换的开销

如何理解 suspend 关键字

TL;DR:

使用 suspend 包装一个耗时操作,同时将耗时操作用 withContext()包装起来,并指定其要使用的线程

suspend 关键字在 kotlin 中用于函数声明,这里需要做一个说明:

一个suspend的函数并不代表其不会在主线程上执行

在 Dispatchers.MAIN 的上下文下,你的函数就是会在主线程上执行的

另外一条说明:

suspend关键字并不是真正实现挂起

它只是一个提醒,提醒调用者我是一个耗时函数,我被我的创建者用挂起的方式放到后台执行,你需要用协程来调用我

例如:

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

fun test() : String {
viewModelScope.launch {
Log.d(TAG, "test in IO thread: " + Thread.currentThread())
delay(1_000)
Log.d(TAG, "do we really sleep about 1000ms? " + Thread.currentThread())
}
return "Test"
}

fun onMainViewClicked() {

viewModelScope.launch {
val rst = test()
_snackBar.postValue(rst)
}
}

我们说,test()在 viewModelScope.launch 时启动了一个协程,这个线程 delay 了 1000ms 后返回 test,那么这段代码的表现就是

1
2
2019-12-02 21:21:39.615 21445-21445/com.example.android.kotlincoroutines D/MainViewModel: test in IO thread: Thread[main,5,main]
2019-12-02 21:21:40.620 21445-21445/com.example.android.kotlincoroutines D/MainViewModel: do we really sleep about 1000ms? Thread[main,5,main]

在第一条日志出现时,_snackBar 就已经调用了 postValue 了,delay 函数并没有阻塞住我们的 viewModelScope

如果我们希望延时 1s 后 postValue 呢?下面这个写法 ok 吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fun test() : String {
viewModelScope.async {
Log.d(TAG, "test in IO thread: " + Thread.currentThread())
delay(1_000)
Log.d(TAG, "do we really sleep about 1000ms? " + Thread.currentThread())
}
return "Test"
}
/**
* Wait one second then display a snackbar.
*/
fun onMainViewClicked() {

viewModelScope.launch {
val rst = test()
_snackBar.postValue(rst)
}
}

结果和 launch 一致,原因也很简单,因为我们没有对 async 内的协程做任何操作,只是让它执行.

为了让它等待 1s,就有两种方式

  1. async#await()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
suspend fun test() : String {
viewModelScope.async {
Log.d(TAG, "test in IO thread: " + Thread.currentThread())
delay(1_000)
Log.d(TAG, "do we really sleep about 1000ms? " + Thread.currentThread())
}.await()
return "Test"
}

/**
* Wait one second then display a snackbar.
*/
fun onMainViewClicked() {

viewModelScope.launch {
val rst = test()
_snackBar.postValue(rst)
}
}

我们会发现一旦调用了 await(),编译器就会要求我们声明 test 为 suspend 函数,原因很简单。如上文所述,await()是被声明为 suspend,它被声明为耗时函数,那么你需要放在一个 suspend 函数去调用

结果:

1
2
2019-12-02 21:26:10.198 21809-21809/com.example.android.kotlincoroutines D/MainViewModel: test in IO thread: Thread[main,5,main]
2019-12-02 21:26:11.202 21809-21809/com.example.android.kotlincoroutines D/MainViewModel: do we really sleep about 1000ms? Thread[main,5,main]

postValue 将会在第二条日志打印时调用,满足延时效果

  1. withContext()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
suspend fun test() : String {
withContext(viewModelScope.coroutineContext) {
Log.d(TAG, "test in IO thread: " + Thread.currentThread())
delay(1_000)
Log.d(TAG, "do we really sleep about 1000ms? " + Thread.currentThread())
}
return "Test"

}
/**
* Wait one second then display a snackbar.
*/
fun onMainViewClicked() {

viewModelScope.launch {
val rst = test()
_snackBar.postValue(rst)
}
}

效果同 1,但这里可以额外说一下,withContext 允许带返回值,你可以理解就是 async#await()的整合版。

这里需要对 1 和 2 做一个总结,async 配合 await()和 withContext 到底干了什么?简单来说

就是帮助你将线程切走,同时当传入的lambda有返回值时,帮你自动切回来

啥意思呢?比如你有下面一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

suspend fun getUser() : User{
return withContext(Dispatchers.IO) {
return network.fetchUser() // IO Thread
}
}


fun showUser() {
viewModelScope.launch {
val user = getUser() // Main Thread
_user.postValue(user)
}
}

我们认为network.fetchUser()是一个耗时操作,那么我们用 withContext(Dispatchers.IO)包裹起来,丢给 IO 线程处理,当 network.fetchUser()返回时,withContext 自动将线程切回 MAIN 线程,回到 showUser()的 user 变量处,这里也是 MAIN 线程

协程中的异常处理

协程中的异常处理取决于你创建协程的方式

如果是 launch 创建的协程,异常会立即抛出,同时会导致其树状关系的所有 job 失败,因此你需要在协程内代码自行使用 try-catch

例如:

1
2
3
4
5
6
7
8
9
val job: Job = Job()
val scope = CoroutineScope(Dispatchers.Default + job)
// may throw Exception
fun doWork(): Deferred<String> = scope.async { ... } // (1)
fun loadData() = scope.launch {
try {
doWork().await() // (2)
} catch (e: Exception) { ... }
}

这个时候(2)中捕获的异常也会立即使其父 job 失败,一种解决方案就是用 SupervisorJob 来作为父 job

A failure or cancellation of a child does not cause the supervisor job to fail and does not affect its other children.

这么做能捕获的前提是你的 async 也在 SupervisorJob 构造的 scope 中执行,不然也是会 crash 的, 例如:

1
2
3
4
5
6
7
8
9
10
val job = SupervisorJob()
val scope = CoroutineScope(Dispatchers.Default + job)

fun loadData() = scope.launch {
try {
async { // (1)
// may throw Exception, still crash
}.await()
} catch (e: Exception) { ... }
}

另一种解决方案就是使用 coroutineScope 函数包裹你的 async 调用

1
2
3
4
5
6
7
8
9
10
11
12
13
val job = SupervisorJob()
val scope = CoroutineScope(Dispatchers.Default + job)

// may throw Exception
suspend fun doWork(): String = coroutineScope { // (1)
async { ... }.await()
}

fun loadData() = scope.launch { // (2)
try {
doWork()
} catch (e: Exception) { ... }
}

如果是 async 创建的协程,异常会在你调用 await 的位置抛出,如果没有调用 await,不会有抛出异常的可能,因此你需要在 await 调用的位置使用 try-catch

如果是 withContext 创建的协程,同 launch 的处理

最后,你都可以创建一个 CoroutineExceptionHandler 对象传入到你的 scope 中来统一处理异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

private val coroutineExceptionHandler: CoroutineExceptionHandler =
CoroutineExceptionHandler { _, throwable ->
//2
coroutineScope.launch(Dispatchers.Main) {
//3
errorMessage.visibility = View.VISIBLE
errorMessage.text = getString(R.string.error_message)
}

GlobalScope.launch { println("Caught $throwable") }
}

private val coroutineScope =
CoroutineScope(Dispatchers.Main + parentJob + coroutineExceptionHandler)

最佳实践

  1. 实现 suspend 函数时,保证其线程安全,可以在任何线程(Dispatcher)调用
1
2
3
4
5
6
7
8
9
10
suspend fun login() : Result {
view.showLoading()

val result = withContext(Dispatchers.IO) {
loginBlockingCall()
}

view.hideLoading()
return result
}

上面的 login 有潜在的 crash 风险,调用方极有可能将这个函数放在 non-Main dispatcher 中调用,为了避免这种类型的 crash,请将你的函数设计成线程安全的

1
2
3
4
5
6
7
8
9
10
suspend fun login() : Result = withContext(Dispachers.Main) {
view.showLoading()

val result = withContext(Dispatchers.IO) {
loginBlockingCall()
}

view.hideLoading()
return result
}
  1. Android 开发应避免 GlobalScope
    因为 GlobalScope 是跟随 Application 的生命周期,使用 GlobalScope 下的协程会带来资源泄漏的风险

Android自定义View中一些参数的含义

Android 自定义 View 中一些参数的含义

TL;DR

不要理会View(Context, AttributeSet, defStyleAttr)View(Context, AttributeSet, defStyleAttr, defStyleRes), 关心View(Context), View(Context, AttributeSet), 范例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MyView extends SomeView {
public MyView(Context context) {
super(context);
init(context, null, 0);
}

public MyView(Context context, AttributeSet attrs) {
super(context,attrs);
init(context, attrs, 0);
}

public MyView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init(context, attrs, defStyle);
}

private void init(Context context, AttributeSet attrs, int defStyle) {
// do something cool
}
}

AttributeSet

这个参数代表了所有在 XML 中申明的属性, 例如:

1
2
3
4
5
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/hello"
undeclaredAttr="value for undeclared attr" />

所有 XML 中调用都会涉及 View 的两个参数的构造函数, 此时 AttributeSet 就会包含四个属性: layout_width, layout_height, text, undeclaredAttr

可能有的人会好奇这些属性是从哪里来的,实际上在自定义 View 时,我们可以在 attrs 中声明declare-styleable来为我们的 View 增加属性, 例如:

1
2
3
4
5
6
7
<declare-styleable name="ImageView">
<!-- Sets a drawable as the content of this ImageView. -->
<attr name="src" format="reference|color" />

<!-- ...snipped for brevity... -->

</declare-styleable>

上面这个就是 ImageView 中 src 属性的由来, 有了这个定义, Android 的 ImageView 才能通过 xml 来设置 src 属性

通常在View(Context context, AttributeSet attrs), 我们会使用Theme.obtainStyledAttributes(android.util.AttributeSet, int[], int, int)来获取 AttributeSet 中的属性, 主要原因是因为我们往往需要依赖一套机制来解决在 xml 配置时使用的各种关系

For example, if you define style=@style/MyStyle in your XML, this method resolves MyStyle and adds its attributes to the mix. In the end, obtainStyledAttributes() returns a TypedArray which you can use to access the attributes.

defStyleAttr

View 的构造函数有一种方法签名: View(Context context, AttributeSet attrs, int defStyleAttr), 其中的 defStyleAttr 是什么含义呢?

defStyleAttr 可以理解为是你的 View 的默认风格,例如,你希望你 app 中的所有 MaterialButton 最小高度都为 72dp,那么你可以在你 app 中的 style.xml 中声明:

1
2
3
4
5
6
7
8
9

<style name="Theme.Demo">
...
<item name="materialButtonStyle">@style/BigButton</item>
</style>

<style name="BigButton" parent="Widget.MaterialComponents.Button">
<item name="android:minHeight">72dp</item>
</style>

这里 MaterialButton 构造时使用的 defStyleAttr 即R.attr.materialButtonStyle, 我们在Theme.Demo的 style 配置中定义了我们自己的materialButtonStyle, 因此 App 内所有用的 MaterialButton 最小高度就是 72dp 了

实际上在上一个 Part 中我们提到Theme.obtainStyledAttributes(android.util.AttributeSet, int[], int, int)时会发现其第三个参数名也是 defStyleAttr, 这里的defStyleAttr和我们在 View 的构造函数里面表达的是同一个意思. 这里举个例子方便大家理解, 以 Android 的 TextView 举例:

首先我们需要声明一下我们 TextView 支持的属性:

1
2
3
4
5
6
7
8
9
10
11
12
<resources>
<declare-styleable name="Theme">

<!-- ...snip... -->

<!-- Default TextView style. -->
<attr name="textViewStyle" format="reference" />

<!-- ...etc... -->

</declare-styleable>
</resource>

这里的 textViewStyle 被我们声明为 reference, 即 textViewStyle 可以被定义为某一种 style

接着我们声明一下我们的 style

1
2
3
4
5
6
7
8
9
10
11
12

<resources>
<style name="Theme">

<!-- ...snip... -->

<item name="textViewStyle">@style/Widget.TextView</item>

<!-- ...etc... -->

</style>
</resource>

在 Manifest 中我们声明使用此 style

1
2
3
4
<activity
android:name=".MyActivity"
android:theme="@style/Theme"
/>

接着在 TextView 构造时我们调用:

1
TypedArray ta = theme.obtainStyledAttributes(attrs, R.styleable.TextView, R.attr.textViewStyle, 0);

此时会产生如下的结果:

  1. 任何在 xml 中显式定义的属性会优先返回
  2. 如果 xml 中没有定义的属性, 那么会从R.styleable.TextView指向的 style, 在这里即@style/Widget.TextView, 取出

defStyleRes

View 的构造函数中还有最后一种: View(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes), 其中的 defStyleRes 又是什么意思呢?

简单来说, 当 defStyleAttr 为 0 或者没有在当前主题中设置 defStyleAttr 时, 就会使用 defStyleRes 对应的 style 来构造这个 View

小结

列举一下在不同位置设置的属性使用的优先级,简单来说优先级从高到低如下排列:

1. Any value defined in the AttributeSet.
2. The style resource defined in the AttributeSet (i.e. style=@style/blah).
3. The default style attribute specified by defStyleAttr.
4. The default style resource specified by defStyleResource (if there was no defStyleAttr).
5. Values in the theme.

P.S 为什么不使用集联方式实现自定义 View

这里有一点需要注意, 往往我们会采用如下方式实现自定义 View:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MyView extends SomeView {
public MyView(Context context) {
this(context, null);
}

public MyView(Context context, AttributeSet attrs) {
this(context,attrs, 0);
}

public MyView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
// do something cool
}
}

这种写法被称为telescopic constructor

但这样写有可能会丢掉继承自 SomeView 的默认风格配置, 因为很有可能 SomeView 就是用集联方式实现构造的, 例如:

1
2
3
4
5
6
7
8
9
10
11
12

public SomeView(Context context) {
this(context, null);
}

public SomeView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, com.android.internal.R.attr.textViewStyle);
}

public SomeView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
this(context, attrs, defStyleAttr, 0);
}

如果你在MyView(Context)中没有调用super(context), 那么你就会丢掉com.android.internal.R.attr.textViewStyle的所有默认属性值, 会给你造成一些麻烦, 因此建议参考TL;DR中的方式实现自定义 View 的构造

References

Stackoverflow
A deep dive into Android View constructors
Resolving View Attributes on Android

工程效率的一些思考

有关工程效率的一些思考

最近工作中花了很多时间处理了团队的devops和版本控制协作的问题,有了一些不成熟的想法,正好也是来到公司刚好一年的时间,借这个机会沉淀一下。

使用键盘,而非UI

我自己使用过一年的Linux作为主力开发环境,同时也有3年左右的Linux服务器维护的经历。这些经历让我更加习惯Shell环境,因此我也有一个习惯,任何开发环境,我一定会使用Shell辅助我开发,同时使用IDE等工具时,总是习惯尽可能用其快捷键位进行操作。

很有意思的是,我在大学期间认识的一部分朋友,大都也有这样类似的习惯。然而现在我身边还是有不少人习惯于用UI的交互方式进行日常开发,而用UI的结果往往是花费了更多时间去处理交互问题,这些交互问题浪费的时间最后积少成多拖慢了整个开发效率。

Shell和UI的本质

Shell的本质其实是一个REPL(Read-Evaluate-Print-Loop),当我们使用Shell操作时,本身就是把自己的操作用文本线性地进行记录

而我们绝大部分时候使用的UI交互工具,往往都是在Shell命令基础上做了层UI交互的封装。基于此我提出一个自己的论断,欢迎打脸:

任何UI工具的功能必然是某一个或多个Shell命令对应功能的子集,并且这个Shell命令必然早于UI工具诞生

我也问过他们为什么更加喜欢用UI(鼠标)来完成日常的开发任务,原因无外乎:

  1. UI更直观
  2. UI更易懂,直接用UI比起用键盘更”快”

但实际果真如此吗?

UI交互真的直观吗

我想到近日有很多时间,我都在帮助我的同事解决他们遇到的各种Git问题。但遇到的情况大都是:

我用了UI上的XXX按钮,点了几个选择框后发现后续的功能没办法继续用了

同时附上一些截图到我的企业IM,问我为什么。

说实话这样我也不知道为什么会出现这个问题。

那假如我们换种描述方式呢?

我执行git merge –no-ff xxx && git pull -r后,我的merge commit不见了,这是为什么呢?

在我看来,下面这种描述是更容易理解和定位问题的,这也是我一直热衷于使用Shell操作的原因。

UI交互相比Shell虽然优化了展示信息的方式,但也增加了你理解和另一个人理解的难度,这有几个原因:

  1. UI封装后,部分描述会跟其对应Shell的描述不一致
  2. UI封装后为了尽可能地展示信息,会将信息和选项罗列,换句话说,你需要同时理解这个UI上的所有信息

UI本身不是文本,其表述方式往往是模糊不清的(如某个确定按钮, 某个选项卡等),这样就只能用图片等方式辅助描述。因此,当你在UI上操作出现问题时,他需要首先理解这个UI所展示的上下文环境后,才能明白你要发生了什么。如果这个UI本身的描述和其对应的Shell不一致,那么你们就会面临一个黑盒,这个黑盒是需要额外的手段才能破解的(如此UI工具的帮助文档等)

所以,UI的直观,只是单纯的在画面上的直观,但是比起工程上要求的精准,UI是绝对不合格的。工程上要求的直观,应该是正确的直观。

UI交互真的比键盘交互快吗

很多人觉得鼠标点击是很快的一件事情,但实际上也不是这样。在一个复杂的UI环境,找到一个功能往往需要非常深的操作路径才能找到。而鼠标本身是一个定位精确度差的硬件,远不如键盘这样按下哪个键就来哪个键精准,操作鼠标还需要更多时间去定位,因此操作上显然还是键盘来的更加精准和快捷。

我们该如何培养使用键盘的意识

很多时候我们的困境是我们并不知道有更好的方式,所以只能保持现状。我在这里给一些如何培养自己多使用键盘提升开发效率的建议,总的来说就是一点:

DRY(Don’t Repeat Yourself) && Search

当你发现有一个UI操作范式你在反复使用时,不妨试着把它转换为一个键盘操作或者Shell命令,然后去使用这个你转换的结果;如果你发现这个操作范式可以被优化,试着去Google一下,往往会有很多人会有类似的疑问,而他们也会给你一个高效的操作方式。大家都是很懒的,没人愿意反复一个枯燥的点点点操作

CanvasKit简介

CanvasKit简介

CanvasKit是以WASM为编译目标的Web平台图形绘制接口,其目标是将Skia的图形API导出到Web平台。
从代码提交记录来看,CanvasKit作为了一个Module放置在整个代码仓库中,最早的一次提交记录在2018年9月左右,是一个比较新的codebase

本文简单介绍一下Skia是如何编译为Web平台的,其性能以及未来的可应用场景

编译原理

整个canvaskit模块的代码量非常少:

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
.gitignore
CHANGELOG.md
Makefile
WasmAliases.h
canvaskit/ 发布代码,canvaskit介绍文档
canvaskit_bindings.cpp
compile.sh 编译脚本
cpu.js
debug.js
externs.js
fonts/ 字体资源文件
gpu.js
helper.js
htmlcanvas/
interface.js
karma.bench.conf.js
karma.conf.js
package.json
particles_bindings.cpp
perf/ 性能数据
postamble.js
preamble.js
ready.js
release.js
serve.py
skottie.js
skottie_bindings.cpp
tests/ 测试代码

整个模块我们可以看到其实没有修改包括任何skia的代码文件,只是在编译时指明了skia的源码依赖,同时写了一些胶水代码,从这里可以看出skia迁移至WASM并没有付出很多额外的改造工作。

编译

设置好WASM工具链EmscriptenSDK的环境变量后运行compile.sh就会在out文件夹中得到canvaskit.jscanvaskit.wasm这两个编译产物,这里为了分析选择编译一个debug版本:

1
./compile.sh debug

debug版本会得到一个未混淆的canvaskit.js,方便我们分析其实现

编译产物浅析

为了快速了解整个模块的情况,直接观察canvaskit.js和canvaskit.wasm文件,先来看下canvaskit.js
js代码量比较大,这里摘取一段最能展示其运行原理的代码:

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
function makeWebGLContext(canvas, attrs) {
// These defaults come from the emscripten _emscripten_webgl_create_context
var contextAttributes = {
alpha: get(attrs, 'alpha', 1),
depth: get(attrs, 'depth', 1),
stencil: get(attrs, 'stencil', 0),
antialias: get(attrs, 'antialias', 1),
premultipliedAlpha: get(attrs, 'premultipliedAlpha', 1),
preserveDrawingBuffer: get(attrs, 'preserveDrawingBuffer', 0),
preferLowPowerToHighPerformance: get(attrs, 'preferLowPowerToHighPerformance', 0),
failIfMajorPerformanceCaveat: get(attrs, 'failIfMajorPerformanceCaveat', 0),
majorVersion: get(attrs, 'majorVersion', 1),
minorVersion: get(attrs, 'minorVersion', 0),
enableExtensionsByDefault: get(attrs, 'enableExtensionsByDefault', 1),
explicitSwapControl: get(attrs, 'explicitSwapControl', 0),
renderViaOffscreenBackBuffer: get(attrs, 'renderViaOffscreenBackBuffer', 0),
};
if (!canvas) {
SkDebug('null canvas passed into makeWebGLContext');
return 0;
}
// This check is from the emscripten version
if (contextAttributes['explicitSwapControl']) {
SkDebug('explicitSwapControl is not supported');
return 0;
}
// GL is an enscripten provided helper
// See https://github.com/emscripten-core/emscripten/blob/incoming/src/library_webgl.js
return GL.createContext(canvas, contextAttributes);
}

CanvasKit.GetWebGLContext = function(canvas, attrs) {
return makeWebGLContext(canvas, attrs);
};

var GL= {
// ...
init:function () {
GL.miniTempBuffer = new Float32Array(GL.MINI_TEMP_BUFFER_SIZE);
for (var i = 0; i < GL.MINI_TEMP_BUFFER_SIZE; i++) {
GL.miniTempBufferViews[i] = GL.miniTempBuffer.subarray(0, i+1);
}
},
//...
createContext:function (canvas, webGLContextAttributes) {
var ctx = (canvas.getContext("webgl", webGLContextAttributes)
|| canvas.getContext("experimental-webgl", webGLContextAttributes));
return ctx && GL.registerContext(ctx, webGLContextAttributes);
},registerContext:function (ctx, webGLContextAttributes) {
var handle = _malloc(8); // Make space on the heap to store GL context attributes that need to be accessible as shared between threads.
assert(handle, 'malloc() failed in GL.registerContext!');
var context = {
handle: handle,
attributes: webGLContextAttributes,
version: webGLContextAttributes.majorVersion,
GLctx: ctx
};
// Store the created context object so that we can access the context given a canvas without having to pass the parameters again.
if (ctx.canvas) ctx.canvas.GLctxObject = context;
GL.contexts[handle] = context;
if (typeof webGLContextAttributes.enableExtensionsByDefault === 'undefined' || webGLContextAttributes.enableExtensionsByDefault) {
GL.initExtensions(context);
}
return handle;
},makeContextCurrent:function (contextHandle) {
GL.currentContext = GL.contexts[contextHandle]; // Active Emscripten GL layer context object.
Module.ctx = GLctx = GL.currentContext && GL.currentContext.GLctx; // Active WebGL context object.
return !(contextHandle && !GLctx);
},
// ...
}

代码中出现了大量的WebGL指令和2d的绘制js代码,其实这一块就是EmscriptenSDK对OpenGL的胶水代码(https://emscripten.org/docs/porting/multimedia_and_graphics/OpenGL-support.html), 换言之,canvaskit的绘制代码没有脱离浏览器提供的webgl和context2d的相关接口,毕竟这也是目前在浏览器进行绘制操作的唯一途径

那编译的wasm文件做了啥呢?简单看一下对应wasm的一部分代码, 这也是一个比较庞大的文件,我们只关注一下wasm和js连接的桥梁代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(import "env" "_eglGetCurrentDisplay" (func $_eglGetCurrentDisplay (result i32)))
(import "env" "_eglGetProcAddress" (func $_eglGetProcAddress (param i32) (result i32)))
(import "env" "_eglQueryString" (func $_eglQueryString (param i32 i32) (result i32)))
(import "env" "_emscripten_glActiveTexture" (func $_emscripten_glActiveTexture (param i32)))
(import "env" "_emscripten_glAttachShader" (func $_emscripten_glAttachShader (param i32 i32)))
(import "env" "_emscripten_glBeginQueryEXT" (func $_emscripten_glBeginQueryEXT (param i32 i32)))
(import "env" "_emscripten_glBindAttribLocation" (func $_emscripten_glBindAttribLocation (param i32 i32 i32)))
(import "env" "_emscripten_glBindBuffer" (func $_emscripten_glBindBuffer (param i32 i32)))
(import "env" "_emscripten_glBindFramebuffer" (func $_emscripten_glBindFramebuffer (param i32 i32)))
(import "env" "_emscripten_glBindRenderbuffer" (func $_emscripten_glBindRenderbuffer (param i32 i32)))
(import "env" "_emscripten_glBindTexture" (func $_emscripten_glBindTexture (param i32 i32)))
(import "env" "_emscripten_glClear" (func $_emscripten_glClear (param i32)))
(import "env" "_emscripten_glClearColor" (func $_emscripten_glClearColor (param f64 f64 f64 f64)))
(import "env" "_emscripten_glClearDepthf" (func $_emscripten_glClearDepthf (param f64)))
(import "env" "_emscripten_glCompileShader" (func $_emscripten_glCompileShader (param i32)))
...

这里省略了一部分,但是仍然可以看出,wasm对绘制的支持全部依赖其运行环境中js注入的函数实现

以这里的_emscripten_glBindTexture函数为例,对应到js为:

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
var asmGlobalArg = {}

var asmLibraryArg = {
"_emscripten_glBindTexture": _emscripten_glBindTexture // .... 上文wasm中的函数注册,这里略去,只保留_emscripten_glBindTexture
}
Module['asm'] = function(global, env, providedBuffer) {
// memory was already allocated (so js could use the buffer)
env['memory'] = wasmMemory
;
// import table
env['table'] = wasmTable = new WebAssembly.Table({
'initial': 1155075,
'maximum': 1155075,
'element': 'anyfunc'
});
env['__memory_base'] = 1024; // tell the memory segments where to place themselves
env['__table_base'] = 0; // table starts at 0 by default (even in dynamic linking, for the main module)

var exports = createWasm(env); // 加载WASM对象, env即WASM对象加载时所需要的上下文,包括内存大小,函数表,和堆栈起始地址
assert(exports, 'binaryen setup failed (no wasm support?)');
return exports;
};
// EMSCRIPTEN_START_ASM
var asm =Module["asm"]// EMSCRIPTEN_END_ASM
(asmGlobalArg, asmLibraryArg, buffer);
function _emscripten_glBindTexture(target, texture) {
GL.validateGLObjectID(GL.textures, texture, 'glBindTexture', 'texture');
GLctx.bindTexture(target, GL.textures[texture]);
}

GLctx通过代码我们也能找到对应:

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
createContext:function (canvas, webGLContextAttributes) {
var ctx = (canvas.getContext("webgl", webGLContextAttributes)
|| canvas.getContext("experimental-webgl", webGLContextAttributes));
return ctx && GL.registerContext(ctx, webGLContextAttributes);
},registerContext:function (ctx, webGLContextAttributes) {
var handle = _malloc(8); // Make space on the heap to store GL context attributes that need to be accessible as shared between threads.
assert(handle, 'malloc() failed in GL.registerContext!');
var context = {
handle: handle,
attributes: webGLContextAttributes,
version: webGLContextAttributes.majorVersion,
GLctx: ctx
};
// Store the created context object so that we can access the context given a canvas without having to pass the parameters again.
if (ctx.canvas) ctx.canvas.GLctxObject = context;
GL.contexts[handle] = context;
if (typeof webGLContextAttributes.enableExtensionsByDefault === 'undefined' || webGLContextAttributes.enableExtensionsByDefault) {
GL.initExtensions(context);
}
return handle;
},makeContextCurrent:function (contextHandle) {

GL.currentContext = GL.contexts[contextHandle]; // Active Emscripten GL layer context object.
Module.ctx = GLctx = GL.currentContext && GL.currentContext.GLctx; // Active WebGL context object. // GLCtx变量
return !(contextHandle && !GLctx);
}

所以这里的bindTexture实际上就是WebGL的bindTexture指令(https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/bindTexture#Syntax)

分析到这里,我们可以得到一个基本结论: canvaskit中绘制的实现全部在canvaskit.js中调用浏览器绘制API来实现,而计算相关的内容全部放在了wasm中实现

编译脚本解析

通过对编译产物的分析,我们可以发现canvaskit绝大部分的绘制都是借助了Web API中的2d或webgl绘制API来完成的。这里需要分析的是canvaskit如何搭建了skia原生绘制代码和浏览器绘制API的桥梁。

看到compile.sh发现最后一句话涉及到很多canvaskit目录下的文件,因此直接结合编译日志的相关内容分析。

其他的日志都是常规的skia编译命令,只不过执行程序换成了em++而已,em++就是EmscriptenSDK中的编译器命令,可以类比为g++,这些命令会把skia编译为几个静态库

我们略过之前的skia编译命令来到最后一段,这是真正生成WASM产物的地方,其中有大量的逻辑是涉及到canvaskit中的胶水代码的。略去链接, 编译器优化设置, Skia静态库路径的指定, Skia宏定义和头文件路径指定,我们将会得到:

script
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
/Users/JianGuo/VSCodeProject/emsdk/emscripten/1.38.28/em++  \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/debug.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/cpu.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/gpu.js \
--bind \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/preamble.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/helper.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/interface.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/skottie.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/preamble.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/util.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/color.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/font.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/canvas2dcontext.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/htmlcanvas.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/imagedata.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/lineargradient.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/path2d.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/pattern.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/radialgradient.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/postamble.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/postamble.js \
--post-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/ready.js \
/Users/JianGuo/Desktop/skia/skia/modules/canvaskit/fonts/NotoMono-Regular.ttf.cpp \
/Users/JianGuo/Desktop/skia/skia/modules/canvaskit/canvaskit_bindings.cpp \
/Users/JianGuo/Desktop/skia/skia/modules/canvaskit/particles_bindings.cpp \
/Users/JianGuo/Desktop/skia/skia/modules/canvaskit/skottie_bindings.cpp \
modules/skottie/utils/SkottieUtils.cpp \
-s ALLOW_MEMORY_GROWTH=1 \ # 允许申请比TOTAL_MEMORY更大的内存
-s EXPORT_NAME=CanvasKitInit \ # js中Module的名字
-s FORCE_FILESYSTEM=0 \ # 开启文件系统支持,用于js中对native的文件系统进行模拟
-s MODULARIZE=1 \ #启用Module的方式生成js,开启后编译的js产物将拥有一个Module作用域,而非全局作用域
-s NO_EXIT_RUNTIME=1 \ # 禁止使用exit函数
-s STRICT=1 \ # 确保编译器不使用弃用语法
-s TOTAL_MEMORY=128MB \ # WASM分配的总内存,如果比此内存更大的场景就需要扩展堆大小
-s USE_FREETYPE=1 \ # 使用emscripten-ports导出的freetype库
-s USE_LIBPNG=1 \ # 使用emscripten-ports导出的libpng库
-s WARN_UNALIGNED=1 \ # 编译时警告未对齐(align)
-s USE_WEBGL2=0 \ # 不使用WebGL2
-s WASM=1 \ # 编译为WASM
-o out/canvaskit_wasm_debug/canvaskit.js # 指定编译路径

其中,pre-js <file>表示将指定文件的内容插入到生成的js文件前, post-js表示将指定文件的内容插入到生成的js文件后,我们以skia/modules/canvaskit/htmlcanvas/htmlcanvas.js为例,看看这些插入的文件都干了啥:

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
CanvasKit.MakeCanvas = function(width, height) {
var surf = CanvasKit.MakeSurface(width, height);
if (surf) {
return new HTMLCanvas(surf);
}
return null;
}

function HTMLCanvas(skSurface) {
this._surface = skSurface;
this._context = new CanvasRenderingContext2D(skSurface.getCanvas());
this._toCleanup = [];
this._fontmgr = CanvasKit.SkFontMgr.RefDefault();

// Data is either an ArrayBuffer, a TypedArray, or a Node Buffer
this.decodeImage = function(data) {
// ...
}

this.loadFont = function(buffer, descriptors) {
//...
}

this.makePath2D = function(path) {
//...
}

// A normal <canvas> requires that clients call getContext
this.getContext = function(type) {
//...
}

this.toDataURL = function(codec, quality) {
//...
}

this.dispose = function() {
//...
}
}

其实就是对齐了一下浏览器实现,同时对齐了一下Skia内部的接口而已。
最后我们还剩下一段没有分析:

script
1
2
3
4
5
6
7
8
9
10
/Users/JianGuo/VSCodeProject/emsdk/emscripten/1.38.28/em++  \
...
--bind \
...
/Users/JianGuo/Desktop/skia/skia/modules/canvaskit/fonts/NotoMono-Regular.ttf.cpp \
/Users/JianGuo/Desktop/skia/skia/modules/canvaskit/canvaskit_bindings.cpp \
/Users/JianGuo/Desktop/skia/skia/modules/canvaskit/particles_bindings.cpp \
/Users/JianGuo/Desktop/skia/skia/modules/canvaskit/skottie_bindings.cpp \
modules/skottie/utils/SkottieUtils.cpp \
...

根据文档,这段命令要求em++以Embind(https://emscripten.org/docs/porting/connecting_cpp_and_javascript/embind.html#embind)连接C++代码和JS代码, embind简单来说就是emscriptenSDK提供的将C/C++代码暴露给JavaScript的便捷能力。这里不做重点介绍,我们直接看canvaskit用到的一个代码:

particles_bindings.cpp:

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
// ...
#include <emscripten.h>
#include <emscripten/bind.h>

using namespace emscripten;

EMSCRIPTEN_BINDINGS(Particles) {
class_<SkParticleEffect>("SkParticleEffect")
.smart_ptr<sk_sp<SkParticleEffect>>("sk_sp<SkParticleEffect>")
.function("draw", &SkParticleEffect::draw, allow_raw_pointers())
.function("start", select_overload<void (double, bool)>(&SkParticleEffect::start))
.function("update", select_overload<void (double)>(&SkParticleEffect::update));

function("MakeParticles", optional_override([](std::string json)->sk_sp<SkParticleEffect> {
static bool didInit = false;
if (!didInit) {
REGISTER_REFLECTED(SkReflected);
SkParticleAffector::RegisterAffectorTypes();
SkParticleDrawable::RegisterDrawableTypes();
didInit = true;
}
SkRandom r;
sk_sp<SkParticleEffectParams> params(new SkParticleEffectParams());
skjson::DOM dom(json.c_str(), json.length());
SkFromJsonVisitor fromJson(dom.root());
params->visitFields(&fromJson);
return sk_sp<SkParticleEffect>(new SkParticleEffect(std::move(params), r));
}));
constant("particles", true);

}

上面代码经过em++编译后会直接将其功能内嵌进wasm文件中。至此,整个编译流程就分析完了

小结

这里用一张图来总结一下整个canvaskit的编译流程, 图中省去了编译器优化和js优化的流程:

可应用场景

根据官方文档(https://skia.org/user/modules/canvaskit), canvaskit基于skia的API设计向web平台提供了更加方便的图形接口,可以说起到了类似GLWrapper的作用。

得益于Skia本身的其他扩展功能,canvaskit相比于浏览器原生绘制能力,支持了许多更加上层的业务级别功能,例如skia的动画模块skottie(https://skia.org/user/modules/skottie)

Skia中的skottie本身就支持Lottie动画解析和播放,由于Skia良好的跨平台能力,Android和iOS平台现在均可以使用Skia框架来播放Lottie动画,canvaskit则运用WebAssembly的技术来将跨平台的范围扩展到web上,使得web平台可以通过canvaskit的skottie相关接口直接播放lottie动画

对于Web应用而言,canvaskit提供了开发者更加友好的图形接口,并提供了常见的图形概念(例如Bitmap,Path等),减少了上层应用开发者对于绘制接口的理解负担,开发者只需要理解Skia的图形概念即可开发图形界面,有了skia他们也不需要理解复杂的webgl指令。

小结

得益于WASM的理念和EmscriptenSDK的能力,越来越多的native库可以直接导出web上供开发者使用。CanvasKit可以说是C++ Library向Web平台迁移的又一最佳实践。EmscriptenSDK已经做到将Skia这种规模的C++项目以WASM的方式迁移至Web平台,并保证其代码功能的一致性。整个迁移的过程的代价也就是编译工具链的替换和一部分胶水代码。