前言
成為一名優秀的Android開發,需要一份完備的知識體系,在這裡,讓我們一起成長為自己所想的那樣~。
前兩篇我們詳細地分析了Android的網路底層框架OKHttp和封裝框架Retrofit的核心原始碼,如果對OKHttp或Retrofit內部機制不瞭解的可以看看Android主流三方庫原始碼分析(一、深入理解OKHttp原始碼)和Android主流三方庫原始碼分析(二、深入理解Retrofit原始碼),除了熱門的網路庫之外,我們還分析了使用最廣泛的圖片載入框架Glide的載入流程,大家讀完這篇原始碼分析實力會有不少提升,有興趣可以看看Android主流三方庫原始碼分析(三、深入理解Glide原始碼)。本篇,我們將會來對目前Android資料庫框架中效能最好的GreenDao來進行較為深入地講解。
一、基本使用流程
1、匯入GreenDao的程式碼生成外掛和庫
// 專案下的build.gradle
buildscript {
...
dependencies {
classpath 'com.android.tools.build:gradle:2.3.0'
classpath 'org.greenrobot:greendao-gradle-plugin:3.2.1'
}
}
// app模組下的build.gradle
apply plugin: 'com.android.application'
apply plugin: 'org.greenrobot.greendao'
...
dependencies {
...
compile 'org.greenrobot:greendao:3.2.0'
}
複製程式碼
2、建立一個實體類,這裡為HistoryData
@Entity
public class HistoryData {
@Id(autoincrement = true)
private Long id;
private long date;
private String data;
}
複製程式碼
3、選擇ReBuild Project,HistoryData會被自動新增Set/get方法,並生成整個專案的DaoMaster、DaoSession類,以及與該實體HistoryData對應的HistoryDataDao。
@Entity
public class HistoryData {
@Id(autoincrement = true)
private Long id;
private long date;
private String data;
@Generated(hash = 1371145256)
public HistoryData(Long id, long date, String data) {
this.id = id;
this.date = date;
this.data = data;
}
@Generated(hash = 422767273)
public HistoryData() {
}
public Long getId() {
return this.id;
}
public void setId(Long id) {
this.id = id;
}
public long getDate() {
return this.date;
}
public void setDate(long date) {
this.date = date;
}
public String getData() {
return this.data;
}
public void setData(String data) {
this.data = data;
}
}
複製程式碼
這裡點明一下這幾個類的作用:
- DaoMaster:所有Dao類的主人,負責整個庫的執行,內部的靜態抽象子類DevOpenHelper繼承並重寫了Android的SqliteOpenHelper。
- DaoSession:作為一個會話層的角色,用於生成相應的Dao物件、Dao物件的註冊,操作Dao的具體物件。
- xxDao(HistoryDataDao):生成的Dao物件,用於進行具體的資料庫操作。
4、獲取並使用相應的Dao物件進行增刪改查操作
DaoMaster.DevOpenHelper devOpenHelper = new DaoMaster.DevOpenHelper(this, Constants.DB_NAME);
SQLiteDatabase database = devOpenHelper.getWritableDatabase();
DaoMaster daoMaster = new DaoMaster(database);
mDaoSession = daoMaster.newSession();
HistoryDataDao historyDataDao = daoSession.getHistoryDataDao();
// 省略建立historyData的程式碼
...
// 增
historyDataDao.insert(historyData);
// 刪
historyDataDao.delete(historyData);
// 改
historyDataDao.update(historyData);
// 查
List<HistoryData> historyDataList = historyDataDao.loadAll();
複製程式碼
本篇文章將會以上述使用流程來對GreenDao的原始碼進行逐步分析,最後會分析下GreenDao中一些優秀的特性,讓讀者朋友們對GreenDao的理解有更一步的加深。
二、GreenDao使用流程分析
1、建立資料庫幫助類物件DaoMaster.DevOpenHelper
DaoMaster.DevOpenHelper devOpenHelper = new DaoMaster.DevOpenHelper(this, Constants.DB_NAME);
複製程式碼
建立GreenDao內部實現的資料庫幫助類物件devOpenHelper,核心原始碼如下:
public class DaoMaster extends AbstractDaoMaster {
...
public static abstract class OpenHelper extends DatabaseOpenHelper {
...
@Override
public void onCreate(Database db) {
Log.i("greenDAO", "Creating tables for schema version " + SCHEMA_VERSION);
createAllTables(db, false);
}
}
public static class DevOpenHelper extends OpenHelper {
...
@Override
public void onUpgrade(Database db, int oldVersion, int newVersion) {
Log.i("greenDAO", "Upgrading schema from version " + oldVersion + " to " + newVersion + " by dropping all tables");
dropAllTables(db, true);
onCreate(db);
}
}
}
複製程式碼
DevOpenHelper自身實現了更新的邏輯,這裡是棄置了所有的表,並且呼叫了OpenHelper實現的onCreate方法用於建立所有的表,其中DevOpenHelper繼承於OpenHelper,而OpenHelper自身又繼承於DatabaseOpenHelper,那麼,這個DatabaseOpenHelper這個類的作用是什麼呢?
public abstract class DatabaseOpenHelper extends SQLiteOpenHelper {
...
// 關注點1
public Database getWritableDb() {
return wrap(getWritableDatabase());
}
public Database getReadableDb() {
return wrap(getReadableDatabase());
}
protected Database wrap(SQLiteDatabase sqLiteDatabase) {
return new StandardDatabase(sqLiteDatabase);
}
...
// 關注點2
public Database getEncryptedWritableDb(String password) {
EncryptedHelper encryptedHelper = checkEncryptedHelper();
return encryptedHelper.wrap(encryptedHelper.getWritableDatabase(password));
}
public Database getEncryptedReadableDb(String password) {
EncryptedHelper encryptedHelper = checkEncryptedHelper();
return encryptedHelper.wrap(encryptedHelper.getReadableDatabase(password));
}
...
private class EncryptedHelper extends net.sqlcipher.database.SQLiteOpenHelper {
...
protected Database wrap(net.sqlcipher.database.SQLiteDatabase sqLiteDatabase) {
return new EncryptedDatabase(sqLiteDatabase);
}
}
複製程式碼
其實,DatabaseOpenHelper也是實現了SQLiteOpenHelper的一個幫助類,它內部可以獲取到兩種不同的資料庫型別,一種是標準型的資料庫StandardDatabase,另一種是加密型的資料庫EncryptedDatabase,從以上原始碼可知,它們內部都通過wrap這樣一個包裝的方法,返回了對應的資料庫型別,我們大致看一下StandardDatabase和EncryptedDatabase的內部實現。
public class StandardDatabase implements Database {
// 這裡的SQLiteDatabase是android.database.sqlite.SQLiteDatabase包下的
private final SQLiteDatabase delegate;
public StandardDatabase(SQLiteDatabase delegate) {
this.delegate = delegate;
}
@Override
public Cursor rawQuery(String sql, String[] selectionArgs) {
return delegate.rawQuery(sql, selectionArgs);
}
@Override
public void execSQL(String sql) throws SQLException {
delegate.execSQL(sql);
}
...
}
public class EncryptedDatabaseStatement implements DatabaseStatement {
// 這裡的SQLiteStatement是net.sqlcipher.database.SQLiteStatement包下的
private final SQLiteStatement delegate;
public EncryptedDatabaseStatement(SQLiteStatement delegate) {
this.delegate = delegate;
}
@Override
public void execute() {
delegate.execute();
}
...
}
複製程式碼
StandardDatabase和EncryptedDatabase這兩個類內部都使用了代理模式給相同的介面新增了不同的具體實現,StandardDatabase自然是使用的Android包下的SQLiteDatabase,而EncryptedDatabaseStatement為了實現加密資料庫的功能,則使用了一個叫做sqlcipher的資料庫加密三方庫,如果你專案下的資料庫需要儲存比較重要的資料,則可以使用getEncryptedWritableDb方法來代替getdWritableDb方法對資料庫進行加密,這樣,我們之後的資料庫操作則會以代理模式的形式間接地使用sqlcipher提供的API去運算元據庫。
2、建立DaoMaster物件
SQLiteDatabase database = devOpenHelper.getWritableDatabase();
DaoMaster daoMaster = new DaoMaster(database);
複製程式碼
首先,DaoMaster作為所有Dao物件的主人,它內部肯定是需要一個SQLiteDatabase物件的,因此,先由DaoMaster的幫助類物件devOpenHelper的getWritableDatabase方法得到一個標準的資料庫類物件database,再由此建立一個DaoMaster物件。
public class DaoMaster extends AbstractDaoMaster {
...
public DaoMaster(SQLiteDatabase db) {
this(new StandardDatabase(db));
}
public DaoMaster(Database db) {
super(db, SCHEMA_VERSION);
registerDaoClass(HistoryDataDao.class);
}
...
}
複製程式碼
在DaoMaster的構造方法中,它首先執行了super(db, SCHEMA_VERSION)方法,即它的父類AbstractDaoMaster的構造方法。
public abstract class AbstractDaoMaster {
...
public AbstractDaoMaster(Database db, int schemaVersion) {
this.db = db;
this.schemaVersion = schemaVersion;
daoConfigMap = new HashMap<Class<? extends AbstractDao<?, ?>>, DaoConfig>();
}
protected void registerDaoClass(Class<? extends AbstractDao<?, ?>> daoClass) {
DaoConfig daoConfig = new DaoConfig(db, daoClass);
daoConfigMap.put(daoClass, daoConfig);
}
...
}
複製程式碼
在AbstractDaoMaster物件的構造方法中,除了記錄當前的資料庫物件db和版本schemaVersion之外,還建立了一個型別為HashMap<Class>, DaoConfig>()的daoConfigMap物件用於儲存每一個DAO對應的資料配置物件DaoConfig,並且Daoconfig物件儲存了對應的Dao物件所必需的資料。最後,在DaoMaster的構造方法中使用了registerDaoClass(HistoryDataDao.class)方法將HistoryDataDao類物件進行了註冊,實際上,就是為HistoryDataDao這個Dao物件建立了相應的DaoConfig物件並將它放入daoConfigMap物件中儲存起來。
3、建立DaoSession物件
mDaoSession = daoMaster.newSession();
複製程式碼
在DaoMaster物件中使用了newSession方法新建了一個DaoSession物件。
public DaoSession newSession() {
return new DaoSession(db, IdentityScopeType.Session, daoConfigMap);
}
複製程式碼
在DaoSeesion的構造方法中,又做了哪些事情呢?
public class DaoSession extends AbstractDaoSession {
...
public DaoSession(Database db, IdentityScopeType type, Map<Class<? extends AbstractDao<?, ?>>, DaoConfig>
daoConfigMap) {
super(db);
historyDataDaoConfig = daoConfigMap.get(HistoryDataDao.class).clone();
historyDataDaoConfig.initIdentityScope(type);
historyDataDao = new HistoryDataDao(historyDataDaoConfig, this);
registerDao(HistoryData.class, historyDataDao);
}
...
}
複製程式碼
首先,呼叫了父類AbstractDaoSession的構造方法。
public class AbstractDaoSession {
...
public AbstractDaoSession(Database db) {
this.db = db;
this.entityToDao = new HashMap<Class<?>, AbstractDao<?, ?>>();
}
protected <T> void registerDao(Class<T> entityClass, AbstractDao<T, ?> dao) {
entityToDao.put(entityClass, dao);
}
...
}
複製程式碼
在AbstractDaoSession構造方法裡面建立了一個實體與Dao物件的對映集合。接下來,在DaoSession的構造方法中還做了2件事:
- 1、建立每一個Dao對應的DaoConfig物件,這裡是historyDataDaoConfig,並且根據IdentityScopeType的型別初始化建立一個相應的IdentityScope,根據type的不同,它有兩種型別,分別是IdentityScopeObject和IdentityScopeLong,它的作用是根據主鍵快取對應的實體資料。當主鍵是數字型別的時候,如long/Long、int/Integer、short/Short、byte/Byte,則使用IdentityScopeLong快取實體資料,當主鍵不是數字型別的時候,則使用IdentityScopeObject快取實體資料。
- 2、根據DaoSession物件和每一個Dao對應的DaoConfig物件,建立與之對應的historyDataDao物件,由於這個專案只建立了一個實體類HistoryData,因此這裡只有一個Dao物件historyDataDao,然後就是註冊Dao物件,其實就是將實體和對應的Dao物件放入entityToDao這個對映集合中儲存起來了。
4、插入原始碼分析
HistoryDataDao historyDataDao = daoSession.getHistoryDataDao();
// 增
historyDataDao.insert(historyData);
複製程式碼
這裡首先在會話層DaoSession中獲取了我們要操作的Dao物件HistoryDataDao,然後插入了一個我們預先建立好的historyData實體物件。其中HistoryDataDao繼承了AbstractDao<HistoryData, Long> 。
public class HistoryDataDao extends AbstractDao<HistoryData, Long> {
...
}
複製程式碼
那麼,這個AbstractDao是幹什麼的呢?
public abstract class AbstractDao<T, K> {
...
public List<T> loadAll() {
Cursor cursor = db.rawQuery(statements.getSelectAll(), null);
return loadAllAndCloseCursor(cursor);
}
...
public long insert(T entity) {
return executeInsert(entity, statements.getInsertStatement(), true);
}
...
public void delete(T entity) {
assertSinglePk();
K key = getKeyVerified(entity);
deleteByKey(key);
}
...
}
複製程式碼
看到這裡,根據程式設計師優秀的直覺,大家應該能猜到,AbstractDao是所有Dao物件的基類,它實現了實體資料的操作如增刪改查。我們接著分析insert是如何實現的,在AbstractDao的insert方法中又呼叫了executeInsert這個方法。在這個方法中,第二個參裡的statements是一個TableStatements物件,它是在AbstractDao初始化構造器時從DaoConfig物件中取出來的,是一個根據指定的表格建立SQL語句的一個幫助類。使用statements.getInsertStatement()則是獲取了一個插入的語句。而第三個引數則是判斷是否是主鍵的標誌。
public class TableStatements {
...
public DatabaseStatement getInsertStatement() {
if (insertStatement == null) {
String sql = SqlUtils.createSqlInsert("INSERT INTO ", tablename, allColumns);
DatabaseStatement newInsertStatement = db.compileStatement(sql);
...
}
return insertStatement;
}
...
}
複製程式碼
在TableStatements的getInsertStatement方法中,主要做了兩件事:
- 1、使用SqlUtils建立了插入的sql語句。
- 2、根據不同的資料庫型別(標準資料庫或加密資料庫)將sql語句編譯成當前資料庫對應的語句。
我們繼續往下分析executeInsert的執行流程。
private long executeInsert(T entity, DatabaseStatement stmt, boolean setKeyAndAttach) {
long rowId;
if (db.isDbLockedByCurrentThread()) {
rowId = insertInsideTx(entity, stmt);
} else {
db.beginTransaction();
try {
rowId = insertInsideTx(entity, stmt);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
if (setKeyAndAttach) {
updateKeyAfterInsertAndAttach(entity, rowId, true);
}
return rowId;
}
複製程式碼
這裡首先是判斷資料庫是否被當前執行緒鎖定,如果是,則直接插入資料,否則為了避免死鎖,則開啟一個資料庫事務,再進行插入資料的操作。最後如果設定了主鍵,則在插入資料之後更新主鍵的值並將對應的實體快取到相應的identityScope中,這一塊的程式碼流程如下所示:
protected void updateKeyAfterInsertAndAttach(T entity, long rowId, boolean lock) {
if (rowId != -1) {
K key = updateKeyAfterInsert(entity, rowId);
attachEntity(key, entity, lock);
} else {
...
}
}
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);
}
}
}
複製程式碼
接著,我們還是繼續追蹤主線流程,在executeInsert這個方法中呼叫了insertInsideTx進行資料的插入。
private long insertInsideTx(T entity, DatabaseStatement stmt) {
synchronized (stmt) {
if (isStandardSQLite) {
SQLiteStatement rawStmt = (SQLiteStatement) stmt.getRawStatement();
bindValues(rawStmt, entity);
return rawStmt.executeInsert();
} else {
bindValues(stmt, entity);
return stmt.executeInsert();
}
}
}
複製程式碼
為了防止併發,這裡使用了悲觀鎖保證了資料的一致性,在AbstractDao這個類中,大量使用了這種鎖保證了它的執行緒安全性。接著,如果當前是標準資料庫,則直接獲取stmt這個DatabaseStatement類對應的原始語句進行實體欄位屬性的繫結和最後的執行插入操作。如果是加密資料庫,則直接使用當前的加密資料庫所屬的插入語句進行實體欄位屬性的繫結和執行最後的插入操作。其中bindValues這個方法對應的實現類就是我們的HistoryDataDao類。
public class HistoryDataDao extends AbstractDao<HistoryData, Long> {
...
@Override
protected final void bindValues(DatabaseStatement stmt, HistoryData entity) {
stmt.clearBindings();
Long id = entity.getId();
if (id != null) {
stmt.bindLong(1, id);
}
stmt.bindLong(2, entity.getDate());
String data = entity.getData();
if (data != null) {
stmt.bindString(3, data);
}
}
@Override
protected final void bindValues(SQLiteStatement stmt, HistoryData entity) {
stmt.clearBindings();
Long id = entity.getId();
if (id != null) {
stmt.bindLong(1, id);
}
stmt.bindLong(2, entity.getDate());
String data = entity.getData();
if (data != null) {
stmt.bindString(3, data);
}
}
...
}
複製程式碼
可以看到,這裡對HistoryData的所有欄位使用對應的資料庫語句進行了繫結操作。這裡最後再提及一下,如果當前資料庫是加密型時,則會使用最開始提及的DatabaseStatement的加密實現類EncryptedDatabaseStatement應用代理模式去使用sqlcipher這個加密型資料庫的insert方法。
5、查詢原始碼分析
經過對插入原始碼的分析,我相信大家對GreenDao內部的機制已經有了一些自己的理解,由於刪除和更新內部的流程比較簡單,且與插入原始碼有異曲同工之妙,這裡就不再贅述了。最後我們再分析下查詢的原始碼,查詢的流程呼叫鏈較長,所以將它的核心流程原始碼直接給出。
List<HistoryData> historyDataList = historyDataDao.loadAll();
public List<T> loadAll() {
Cursor cursor = db.rawQuery(statements.getSelectAll(), null);
return loadAllAndCloseCursor(cursor);
}
protected List<T> loadAllAndCloseCursor(Cursor cursor) {
try {
return loadAllFromCursor(cursor);
} finally {
cursor.close();
}
}
protected List<T> loadAllFromCursor(Cursor cursor) {
int count = cursor.getCount();
...
boolean useFastCursor = false;
if (cursor instanceof CrossProcessCursor) {
window = ((CrossProcessCursor) cursor).getWindow();
if (window != null) {
if (window.getNumRows() == count) {
cursor = new FastCursor(window);
useFastCursor = true;
} else {
...
}
}
}
if (cursor.moveToFirst()) {
...
try {
if (!useFastCursor && window != null && identityScope != null) {
loadAllUnlockOnWindowBounds(cursor, window, list);
} else {
do {
list.add(loadCurrent(cursor, 0, false));
} while (cursor.moveToNext());
}
} finally {
...
}
}
return list;
}
複製程式碼
最終,loadAll方法將會呼叫到loadAllFromCursor這個方法,首先,如果當前的遊標cursor是跨程式的cursor,並且cursor的行數沒有偏差的話,則使用一個加快版的FastCursor物件進行遊標遍歷。接著,不管是執行loadAllUnlockOnWindowBounds這個方法還是直接載入當前的資料列表list.add(loadCurrent(cursor, 0, false)),最後都會呼叫到這行list.add(loadCurrent(cursor, 0, false))程式碼,很明顯,loadCurrent方法就是載入資料的方法。
final protected T loadCurrent(Cursor cursor, int offset, boolean lock) {
if (identityScopeLong != null) {
...
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) {
...
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 {
...
T entity = readEntity(cursor, offset);
attachEntity(entity);
return entity;
}
}
複製程式碼
我們來理解下loadCurrent這個方法內部的執行策略。首先,如果有實體資料快取identityScopeLong/identityScope,則先從快取中取,如果快取中沒有,會使用該實體對應的Dao物件,這裡的是HistoryDataDao,它在內部根據遊標取出的資料新建了一個新的HistoryData實體物件返回。
@Override
public HistoryData readEntity(Cursor cursor, int offset) {
HistoryData entity = new HistoryData( //
cursor.isNull(offset + 0) ? null : cursor.getLong(offset + 0), // id
cursor.getLong(offset + 1), // date
cursor.isNull(offset + 2) ? null : cursor.getString(offset + 2) // data
);
return entity;
}
複製程式碼
最後,如果是非identityScopeLong快取型別,即是屬於identityScope的情況下,則還會在identityScope中將上面獲得的資料進行快取。如果沒有實體資料快取的話,則直接呼叫readEntity組裝資料返回即可。
注意:對於GreenDao快取的特性,可能會出現沒有拿到最新資料的bug,因此,如果遇到這種情況,可以使用DaoSession的clear方法刪除快取。
三、GreenDao是如何與ReactiveX結合?
首先,看下與rx結合的使用流程:
RxDao<HistoryData, Long> xxDao = daoSession.getHistoryDataDao().rx();
xxDao.insert(historyData)
.observerOn(AndroidSchedulers.mainThread())
.subscribe(new Action1<HistoryData>() {
@Override
public void call(HistoryData entity) {
// insert success
}
});
複製程式碼
在AbstractDao物件的.rx()方法中,建立了一個預設執行在io執行緒的rxDao物件。
@Experimental
public RxDao<T, K> rx() {
if (rxDao == null) {
rxDao = new RxDao<>(this, Schedulers.io());
}
return rxDao;
}
複製程式碼
接著分析rxDao的insert方法。
@Experimental
public Observable<T> insert(final T entity) {
return wrap(new Callable<T>() {
@Override
public T call() throws Exception {
dao.insert(entity);
return entity;
}
});
}
複製程式碼
起實質作用的就是這個wrap方法了,在這個方法裡面主要是呼叫了RxUtils.fromCallable(callable)這個方法。
@Internal
class RxBase {
...
protected <R> Observable<R> wrap(Callable<R> callable) {
return wrap(RxUtils.fromCallable(callable));
}
protected <R> Observable<R> wrap(Observable<R> observable) {
if (scheduler != null) {
return observable.subscribeOn(scheduler);
} else {
return observable;
}
}
...
}
複製程式碼
在RxUtils的fromCallable這個方法內部,其實就是使用defer這個延遲操作符來進行被觀察者事件的傳送,主要目的就是為了確保Observable被訂閱後才執行。最後,如果排程器scheduler存在的話,將通過外部的wrap方法將執行環境排程到io執行緒。
@Internal
class RxUtils {
@Internal
static <T> Observable<T> fromCallable(final Callable<T> callable) {
return Observable.defer(new Func0<Observable<T>>() {
@Override
public Observable<T> call() {
T result;
try {
result = callable.call();
} catch (Exception e) {
return Observable.error(e);
}
return Observable.just(result);
}
});
}
}
複製程式碼
四、總結
在分析完GreenDao的核心原始碼之後,我發現,GreenDao作為最好的資料庫框架之一,是有一定道理的。首先,它通過使用自身的外掛配套相應的freemarker模板生成所需的靜態程式碼,避免了反射等消耗效能的操作。其次,它內部提供了實體資料的對映快取機制,能夠進一步加快查詢速度。對於不同資料庫對應的SQL語句,也使用了不同的DataBaseStatement實現類結合代理模式進行了封裝,遮蔽了資料庫操作等繁瑣的細節。最後,它使用了sqlcipher提供了加密資料庫的功能,在一定程度確保了安全性,同時,結合RxJava,我們便能更簡潔地實現非同步的資料庫操作。GreenDao原始碼分析到這裡就真的完結了,下一篇,筆者將會對RxJava的核心原始碼進行細緻地講解,以此能讓大家對RxJava有一個更為深入的理解。
參考連結:
1、GreenDao V3.2.2原始碼
讚賞
如果這個庫對您有很大幫助,您願意支援這個專案的進一步開發和這個專案的持續維護。你可以掃描下面的二維碼,讓我喝一杯咖啡或啤酒。非常感謝您的捐贈。謝謝!
Contanct Me
● 微信:
歡迎關注我的微信:
bcce5360
● 微信群:
微信群如果不能掃碼加入,麻煩大家想進微信群的朋友們,加我微信拉你進群。
● QQ群:
2千人QQ群,Awesome-Android學習交流群,QQ群號:959936182, 歡迎大家加入~
About me
-
Email: chao.qu521@gmail.com
-
Blog: jsonchao.github.io/
-
掘金: juejin.im/user/5a3ba9…