Room是怎樣和LiveData結(jié)合使用的染乌?(源碼分析)

前言

之前寫項目的時候山孔,對于數(shù)據(jù)庫的操作不是特別多,能避免就盡量避免荷憋,并且一直想不到比較好的方法去組織網(wǎng)絡(luò)數(shù)據(jù)台颠、本地數(shù)據(jù)的邏輯。所以在最近的面試中時勒庄,問及項目中的數(shù)據(jù)庫實現(xiàn)串前,以及比較好用的數(shù)據(jù)庫的框架及其實現(xiàn)原理時,我就只答道之前在《第一行代碼》中看到了的LitePal实蔽,但源碼就...所以這次來惡補(bǔ)一次數(shù)據(jù)庫荡碾。幾經(jīng)搜索,云比較盐须,比較青睞官方Jetpack組件中的Room玩荠。

Room簡介

Room框架是使用生成代碼的方式在編譯時生成CRUD的代碼,因此性能是遠(yuǎn)遠(yuǎn)好過通過反射實現(xiàn)的ORM框架贼邓。但是事實上阶冈,Room最吸引我的地方不止是性能,Room對架構(gòu)組件(LiveData)塑径、RxJava等流行框架做了適配女坑。例如,Room中的查詢操作可以返回一個LiveData<XXX>统舀,并且匆骗,每一次RUD操作劳景,都會更新LiveData。這可以大大簡化本地碉就、內(nèi)存盟广、網(wǎng)絡(luò)多級緩存的實現(xiàn),具體官方也給出了一系列Demo瓮钥,并且隨時都在隨著框架或者根據(jù)PR更新筋量,強(qiáng)烈推薦研究這些Demo!

本文主要是對Room中與LiveData的聯(lián)動作出分析碉熄,閱讀本文前建議先熟悉Room的基本使用桨武,建議看一下與LiveData配合使用的Demo。

正文

創(chuàng)建相關(guān)類

AppDatabase.kt

@Database(entities = [Book::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun bookDao(): BookDao
}

Book.kt

@Dao
interface BookDao {
    @Insert
    fun insert(book: Book): Long

    @Delete
    fun delete(book: Book)
    
    @Query("select * from book where id = :id")
    fun queryById(id: Long): LiveData<Book>
}

使用數(shù)據(jù)庫:

val db = Room.databaseBuilder(applicationContext, AppDatabase::class.java, "test.db")
            .build()
        db.bookDao().queryById(1).observe(this, Observer {
            // do something when book update
        })

這樣在Observer里面就可以接收到任何時候數(shù)據(jù)庫id=1的數(shù)據(jù)修改操作了锈津。

生成代碼并分析

Build -> Make Project 編譯呀酸,會生成Room相關(guān)代碼,如果是kapt的話琼梆,生成代碼目錄應(yīng)該是{項目目錄}/app/build/generated/source/kapt/debug/{包路徑}/下性誉。
我們可以看到生成了AppDatabase_Impl和BookDao_Impl兩個代碼文件,分別對應(yīng)前面貼出來的AppDatabase的實現(xiàn)類和BookDao的實現(xiàn)類茎杂。

image

AppDatabase_Impl則是表的創(chuàng)建艾栋、刪除相關(guān)代碼,Dao則是具體表的CRUD操作蛉顽。這里我們重點關(guān)系生成的查詢方法。
BookDao_Impl#

@Override
public LiveData<Book> queryById(final long id) {
    final String _sql = "select * from book where id = ?";
    final RoomSQLiteQuery _statement = RoomSQLiteQuery.acquire(_sql, 1);
    int _argIndex = 1;
    _statement.bindLong(_argIndex, id);
    return __db.getInvalidationTracker().createLiveData(new String[]{"book"}, new Callable<Book>() {
        @Override
        public Book call() throws Exception {
            final Cursor _cursor = DBUtil.query(__db, _statement, false);
            try {
                final int _cursorIndexOfId = CursorUtil.getColumnIndexOrThrow(_cursor, "id");
                final int _cursorIndexOfName = CursorUtil.getColumnIndexOrThrow(_cursor, "name");
                final int _cursorIndexOfAuthor = CursorUtil.getColumnIndexOrThrow(_cursor, "author");
                final int _cursorIndexOfPrice = CursorUtil.getColumnIndexOrThrow(_cursor, "price");
                final Book _result;
                if (_cursor.moveToFirst()) {
                    final long _tmpId;
                    _tmpId = _cursor.getLong(_cursorIndexOfId);
                    final String _tmpName;
                    _tmpName = _cursor.getString(_cursorIndexOfName);
                    final String _tmpAuthor;
                    _tmpAuthor = _cursor.getString(_cursorIndexOfAuthor);
                    final float _tmpPrice;
                    _tmpPrice = _cursor.getFloat(_cursorIndexOfPrice);
                    _result = new Book(_tmpId, _tmpName, _tmpAuthor, _tmpPrice);
                } else {
                    _result = null;
                }
                return _result;
            } finally {
                _cursor.close();
            }
        }

        @Override
        protected void finalize() {
            _statement.release();
        }
    });
}

注意這一行

return __db.getInvalidationTracker().createLiveData(...);

我們跟進(jìn)去先较,最終創(chuàng)建的是一個RoomTrackingLiveData携冤,是一個繼承了LiveData的類。下面是它的構(gòu)造方法闲勺。從構(gòu)造方法來看曾棕,比較可疑的對象的是InvalidationTracker.Observer這個類,并且實現(xiàn)十有八九是觀察者模式菜循。而最后的回調(diào)也多半是onInvalidated方法翘地。

@SuppressLint("RestrictedApi")
RoomTrackingLiveData(
        RoomDatabase database,
        InvalidationLiveDataContainer container,
        Callable<T> computeFunction,
        String[] tableNames) {
    mDatabase = database;
    mComputeFunction = computeFunction;
    mContainer = container;
    mObserver = new InvalidationTracker.Observer(tableNames) {
        @Override
        public void onInvalidated(@NonNull Set<String> tables) {
            ArchTaskExecutor.getInstance().executeOnMainThread(mInvalidationRunnable);
        }
    };
}

而在RoomTrackingLiveData中,重寫了onActive方法癌幕。其中mContainer是InvalidationLiveDataContainer衙耕,文檔上有寫僅僅是維護(hù)LiveData的強(qiáng)引用,防止正在使用的LiveData被回收勺远,跟本文目標(biāo)沒關(guān)系橙喘,可忽略。而后面的就有意思了胶逢,通過Excutor執(zhí)行了一個任務(wù)厅瞎,所以饰潜,我們來看一下這個任務(wù)把。

@Override
protected void onActive() {
    super.onActive();
    mContainer.onActive(this);
    mDatabase.getQueryExecutor().execute(mRefreshRunnable);
}

mRefreshRunnable#run()

// mRegisteredObserver是否注冊的標(biāo)志
if (mRegisteredObserver.compareAndSet(false, true)) {
    mDatabase.getInvalidationTracker().addWeakObserver(mObserver);
}
boolean computed;
do {
    computed = false;
    if (mComputing.compareAndSet(false, true)) {
        try {
            T value = null;
            while (mInvalid.compareAndSet(true, false)) {
                computed = true;
                try {
                    // Dao實現(xiàn)類中返回LiveData時傳入的一個參數(shù)和簸,用于查詢彭雾,并將數(shù)據(jù)組裝成一個實體類
                    value = mComputeFunction.call();
                } catch (Exception e) {
                    throw new RuntimeException("Exception while computing database"
                            + " live data.", e);
                }
            }
            if (computed) {
                postValue(value);
            }
        } finally {
            mComputing.set(false);
        }
    }
} while (computed && mInvalid.get());

這段代碼后段通過CAS去完成一次數(shù)據(jù)庫的查詢,組裝成實體類并postValue锁保,即更新LiveData薯酝。
注意到這個代碼前段調(diào)用了InvalidationTracker的addWeakObserver,這個方法就應(yīng)該就是訂閱了身诺。

InvalidationTracker#addWeakObserver

public void addWeakObserver(Observer observer) {
    addObserver(new WeakObserver(this, observer));
}

InvalidationTracker#addObserver

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();
    }
}

InvalidationTracker$WeakObserver

static class WeakObserver extends Observer {
    final InvalidationTracker mTracker;
    final WeakReference<Observer> mDelegateRef;

    WeakObserver(InvalidationTracker tracker, Observer delegate) {
        super(delegate.mTables);
        mTracker = tracker;
        mDelegateRef = new WeakReference<>(delegate);
    }

    @Override
    public void onInvalidated(@NonNull Set<String> tables) {
        final Observer observer = mDelegateRef.get();
        if (observer == null) {
            mTracker.removeObserver(this);
        } else {
            observer.onInvalidated(tables);
        }
    }
}

可以看到蜜托,WeakObserver就是對Observer一個弱引用的包裝。而在addObserver中霉赡,根據(jù)observer中tableNames橄务,對更新了InvalidationTracker的訂閱記錄。添加成功后穴亏,最后會調(diào)用onAdded蜂挪。

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;
}

這里mTableObservers是對每個table的observer進(jìn)行計數(shù)。為什么要計數(shù)呢嗓化?我們接著看棠涮。在發(fā)現(xiàn)了訂閱數(shù)從0->1的table時,這個方法會返回true刺覆,如果它返回true严肪,會執(zhí)行syncTriggers()方法,經(jīng)過調(diào)用會執(zhí)行這一段代碼:

final int[] tablesToSync = mObservedTableTracker.getTablesToSync();
if (tablesToSync == null) {
    return;
}
final int limit = tablesToSync.length;
try {
    database.beginTransaction();
    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();
}

InvalidationTracker#getTablesToSync()

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;
    }
}

這個getTablesToSync方法很短谦屑,但這里就體現(xiàn)了observer計數(shù)的作用驳糯,它遍歷這個表,找出計數(shù)與之前不一樣的氢橙,如果由一個大于0的數(shù)變?yōu)?>0酝枢,表明現(xiàn)在沒有observer訂閱它,返回REMOVE悍手,0->n帘睦,返回ADD,否則NO_OP坦康。對于返回ADD的表竣付,就應(yīng)該是會監(jiān)聽變化的表了。它會執(zhí)行startTrackingTable方法涝焙。

private void startTrackingTable(SupportSQLiteDatabase writableDb, int tableId) {
    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 INSERT OR REPLACE INTO ")
                .append(UPDATE_TABLE_NAME)
                .append(" VALUES(null, ")
                .append(tableId)
                .append("); END");
        writableDb.execSQL(stringBuilder.toString());
    }
}

到這里我們就很清楚了:實現(xiàn)監(jiān)聽修改的方法是觸發(fā)器卑笨。 (不過我之前僅僅是聽說過觸發(fā)器,很少用過仑撞,如果不了解赤兴,這里有一份簡易的教程)妖滔。而觸發(fā)器關(guān)心的操作是這一些:

private static final String[] TRIGGERS = new String[]{"UPDATE", "DELETE", "INSERT"};

對應(yīng)著更新、刪除桶良、插入座舍。當(dāng)有這些操作時,根據(jù)上述觸發(fā)器語句陨帆,會更新一個由InvalidationTracker維護(hù)的表"UPDATE_TABLE_NAME"曲秉。
InvalidationTracker#UPDATE_TABLE_NAME

private static final String UPDATE_TABLE_NAME = "room_table_modification_log";

InvalidationTracker#internalInit

void internalInit(SupportSQLiteDatabase database) {
    synchronized (this) {
        if (mInitialized) {
            Log.e(Room.LOG_TAG, "Invalidation tracker is initialized twice :/.");
            return;
        }

        database.beginTransaction();
        try {
            database.execSQL("PRAGMA temp_store = MEMORY;");
            database.execSQL("PRAGMA recursive_triggers='ON';");
            database.execSQL(CREATE_TRACKING_TABLE_SQL);
            database.setTransactionSuccessful();
        } finally {
            database.endTransaction();
        }
        syncTriggers(database);
        mCleanupStatement = database.compileStatement(RESET_UPDATED_TABLES_SQL);
        mInitialized = true;
    }
}

注意到表中有這樣一列:

INVALIDATED_COLUMN_NAME + " INTEGER NOT NULL DEFAULT 0

在觸發(fā)器設(shè)置的是更新操作時會被設(shè)置為1。所以疲牵,應(yīng)該就是檢驗這個值來判斷表是否有更新承二。那么是哪里進(jìn)行判斷呢?我們可以從一個更新操作開始找纲爸,例如BookDao_Impl#insert()

@Override
public long insert(final Book book) {
    __db.beginTransaction();
    try {
        long _result = __insertionAdapterOfBook.insertAndReturnId(book);
        __db.setTransactionSuccessful();
        return _result;
    } finally {
        __db.endTransaction();
    }
}

最后發(fā)現(xiàn)在endTransaction中調(diào)用了InvalidationTracker的refreshVersionsAsync方法亥鸠。而在這個方法中,最終會運行InvalidationTracker的mRefreshRunnable對象的run方法识啦。(注意负蚊,和上文的mRefreshRunnbale屬于不同類,不是同一個對象颓哮。)
RoomDatabase#endTransaction()

public void endTransaction() {
    mOpenHelper.getWritableDatabase().endTransaction();
    if (!inTransaction()) {
        // enqueue refresh only if we are NOT in a transaction. Otherwise, wait for the last
        // endTransaction call to do it.
        mInvalidationTracker.refreshVersionsAsync();
    }
}

InvalidationTracker#mRefreshRunnable#run()

inal Lock closeLock = mDatabase.getCloseLock();
boolean hasUpdatedTable = false;
try {
    ... 省略

    if (mDatabase.mWriteAheadLoggingEnabled) {
        // 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 {
            hasUpdatedTable = checkUpdatedTable();
            db.setTransactionSuccessful();
        } finally {
            db.endTransaction();
        }
    } else {
        hasUpdatedTable = 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 (hasUpdatedTable) {
    // 分發(fā)給Observer家妆,最終會更新LiveData
    synchronized (mObserverMap) {
        for (Map.Entry<Observer, ObserverWrapper> entry : mObserverMap) {
            entry.getValue().notifyByTableVersions(mTableInvalidStatus);
        }
    }
    // Reset invalidated status flags.
    mTableInvalidStatus.clear();
}

注意,hasUpdatedTable = checkUpdatedTable();

private boolean checkUpdatedTable() {
    boolean hasUpdatedTable = false;
    Cursor cursor = mDatabase.query(new SimpleSQLiteQuery(SELECT_UPDATED_TABLES_SQL));
    //noinspection TryFinallyCanBeTryWithResources
    try {
        while (cursor.moveToNext()) {
            final int tableId = cursor.getInt(0);
            mTableInvalidStatus.set(tableId);
            hasUpdatedTable = true;
        }
    } finally {
        cursor.close();
    }
    if (hasUpdatedTable) {
        mCleanupStatement.executeUpdateDelete();
    }
    return hasUpdatedTable;
}
@VisibleForTesting
static final String SELECT_UPDATED_TABLES_SQL = "SELECT * FROM " + UPDATE_TABLE_NAME
        + " WHERE " + INVALIDATED_COLUMN_NAME + " = 1;";

果然冕茅,是查找"UPDATE_TABLE_NAME"這個表中"INVALIDATED_COLUMN_NAME"這列為1的記錄伤极,然后設(shè)置自己的狀態(tài)。完成這個過程就分發(fā)給自己的Observers姨伤。

void notifyByTableVersions(BitSet tableInvalidStatus) {
    ...
    if (invalidatedTables != null) {
        mObserver.onInvalidated(invalidatedTables);
    }
}

而在前文中有說到塑荒,注冊的Observer實際上是RoomTrackingLiveData的mObserver的包裝,最終會調(diào)用到它的onInvalidated姜挺。

mObserver = new InvalidationTracker.Observer(tableNames) {
    @Override
    public void onInvalidated(@NonNull Set<String> tables) {
        ArchTaskExecutor.getInstance().executeOnMainThread(mInvalidationRunnable);
    }
}
final Runnable mInvalidationRunnable = new Runnable() {
    @MainThread
    @Override
    public void run() {
        boolean isActive = hasActiveObservers();
        if (mInvalid.compareAndSet(false, true)) {
            if (isActive) {
                mDatabase.getQueryExecutor().execute(mRefreshRunnable);
            }
        }
    }
};

可見,最后會在線程池中執(zhí)行RoomTrackingLiveData的mRefreshRunnable任務(wù)彼硫。這個任務(wù)前文已經(jīng)分析過了炊豪,通過CAS的方式查詢數(shù)據(jù),并post給LiveData拧篮,這樣就實現(xiàn)了數(shù)據(jù)更新的通知词渤。到這里,Room和LiveData聯(lián)動的工作原理就大致分析完畢串绩。

寫文章不易缺虐,轉(zhuǎn)載請注明出處@漁船Mr_Liu

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市礁凡,隨后出現(xiàn)的幾起案子高氮,更是在濱河造成了極大的恐慌慧妄,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,997評論 6 502
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件剪芍,死亡現(xiàn)場離奇詭異塞淹,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)罪裹,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,603評論 3 392
  • 文/潘曉璐 我一進(jìn)店門饱普,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人状共,你說我怎么就攤上這事套耕。” “怎么了峡继?”我有些...
    開封第一講書人閱讀 163,359評論 0 353
  • 文/不壞的土叔 我叫張陵冯袍,是天一觀的道長。 經(jīng)常有香客問我鬓椭,道長颠猴,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,309評論 1 292
  • 正文 為了忘掉前任小染,我火速辦了婚禮翘瓮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘裤翩。我一直安慰自己资盅,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 67,346評論 6 390
  • 文/花漫 我一把揭開白布踊赠。 她就那樣靜靜地躺著呵扛,像睡著了一般。 火紅的嫁衣襯著肌膚如雪筐带。 梳的紋絲不亂的頭發(fā)上今穿,一...
    開封第一講書人閱讀 51,258評論 1 300
  • 那天,我揣著相機(jī)與錄音伦籍,去河邊找鬼蓝晒。 笑死,一個胖子當(dāng)著我的面吹牛帖鸦,可吹牛的內(nèi)容都是我干的芝薇。 我是一名探鬼主播,決...
    沈念sama閱讀 40,122評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼作儿,長吁一口氣:“原來是場噩夢啊……” “哼洛二!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,970評論 0 275
  • 序言:老撾萬榮一對情侶失蹤晾嘶,失蹤者是張志新(化名)和其女友劉穎妓雾,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體变擒,經(jīng)...
    沈念sama閱讀 45,403評論 1 313
  • 正文 獨居荒郊野嶺守林人離奇死亡君珠,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,596評論 3 334
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了娇斑。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片策添。...
    茶點故事閱讀 39,769評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖毫缆,靈堂內(nèi)的尸體忽然破棺而出唯竹,到底是詐尸還是另有隱情,我是刑警寧澤苦丁,帶...
    沈念sama閱讀 35,464評論 5 344
  • 正文 年R本政府宣布浸颓,位于F島的核電站,受9級特大地震影響旺拉,放射性物質(zhì)發(fā)生泄漏产上。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,075評論 3 327
  • 文/蒙蒙 一蛾狗、第九天 我趴在偏房一處隱蔽的房頂上張望晋涣。 院中可真熱鬧,春花似錦沉桌、人聲如沸谢鹊。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,705評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽佃扼。三九已至,卻和暖如春蔼夜,著一層夾襖步出監(jiān)牢的瞬間兼耀,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,848評論 1 269
  • 我被黑心中介騙來泰國打工求冷, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留翠订,地道東北人。 一個月前我還...
    沈念sama閱讀 47,831評論 2 370
  • 正文 我出身青樓遵倦,卻偏偏與公主長得像,于是被迫代替她去往敵國和親官撼。 傳聞我的和親對象是個殘疾皇子梧躺,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,678評論 2 354

推薦閱讀更多精彩內(nèi)容