DataStore

Jetpack 的 DataStore 是一種數據存儲解決方案撕贞,可以像 SharedPreferences 一樣存儲鍵值對或使用 protocol buffers 存儲類型化的對象。 DataStore 使用 Kotlin 的協程和 Flow 以異步的竞慢、一致性的落蝙、事務性的方式來存儲數據织狐,對比 SharedPreferences 有許多改進和優(yōu)化,主要作為 SharedPreferences 的替代品筏勒,并且由 SharedPreferences 遷移非常方便移迫。

DataStore 提供了兩種方式:

  • Preferences DataStore:以鍵值對的形式存儲在本地,和 SP 類似管行,但是 DataStore 是基于 Flow 實現的厨埋,不會阻塞主線程,但不能保證類型安全捐顷。

  • Proto DataStore:存儲自定義數據類型的對象(typed objects)荡陷,通過 protocol buffers 將對象序列化存儲在本地,這要求通過 protocol buffers 預先定義 schema迅涮,但是能保證類型安全废赞。

既然 DataStore 是 SP 的替代和改進,那 SP 存在著什么問題需要被改進呢叮姑?

SharedPreferences 的不足

SharedPreference 是一個輕量級的數據存儲方式唉地,使用起來非常方便,以鍵值對的形式存儲在本地传透,但存在以下問題:

通過 getXXX() 方法獲取數據耘沼,可能會導致主線程阻塞

所有 getXXX() 方法都是同步的,在主線程調用 get 方法朱盐,必須等待 SP 加載完畢群嗤,初始化 SP 的時候,會將整個 xml 文件內容加載內存中兵琳,如果文件很大狂秘,讀取較慢骇径,會導致主線程阻塞。

val sp = getSharedPreferences("ByteCode", Context.MODE_PRIVATE) // 異步加載 SP 文件內容
sp.getString("jetpack", ""); // 等待 SP 加載完畢

getSharedPreferences 時開啟一個線程異步讀取數據赃绊,最終會進入SharedPreferencesImplloadFromDisk方法:

private void loadFromDisk() {
    synchronized (mLock) {
        if (mLoaded) {
            return;
        }
        if (mBackupFile.exists()) {
            mFile.delete();
            mBackupFile.renameTo(mFile);
        }
    }
 
    // Debugging
    if (mFile.exists() && !mFile.canRead()) {
        Log.w(TAG, "Attempt to read preferences file " + mFile + " without permission");
    }
 
    Map<String, Object> map = null;
    StructStat stat = null;
    Throwable thrown = null;
    try {
        stat = Os.stat(mFile.getPath());
        if (mFile.canRead()) {
            BufferedInputStream str = null;
            try {
                str = new BufferedInputStream(
                        new FileInputStream(mFile), 16 * 1024);
                map = (Map<String, Object>) XmlUtils.readMapXml(str);
            } catch (Exception e) {
                Log.w(TAG, "Cannot read " + mFile.getAbsolutePath(), e);
            } finally {
                IoUtils.closeQuietly(str);
            }
        }
    } catch (ErrnoException e) {
        // An errno exception means the stat failed. Treat as empty/non-existing by
        // ignoring.
    } catch (Throwable t) {
        thrown = t;
    }
 
    synchronized (mLock) {
        mLoaded = true;
        mThrowable = thrown;
 
        // It's important that we always signal waiters, even if we'll make
        // them fail with an exception. The try-finally is pretty wide, but
        // better safe than sorry.
        try {
            if (thrown == null) {
                if (map != null) {
                    mMap = map;
                    mStatTimestamp = stat.st_mtim;
                    mStatSize = stat.st_size;
                } else {
                    mMap = new HashMap<>();
                }
            }
            // In case of a thrown exception, we retain the old map. That allows
            // any open editors to commit and store updates.
        } catch (Throwable t) {
            mThrowable = t;
        } finally {
            mLock.notifyAll();
        }
    }
}

在這里通過對象鎖 mLock機制來對其進行加鎖操作既峡。只有當 SP 文件中的數據全部讀取完畢之后才會調用mLock.notifyAll() 來釋放鎖,而 get 方法會在 awaitLoadedLocked 方法中調用 mLock.wait()來等待SP 的初始化完成碧查。所以雖然這是異步方法运敢,但當讀取的文件比較大時,還沒讀取完忠售,接著調用 getXXX() 方法需等待其完成传惠,就可能導致主線程阻塞。

SharedPreference 不能保證類型安全

調用 getXXX() 方法的時候稻扬,可能會出現 ClassCastException 異常卦方,因為使用相同的 key 進行操作的時候,putXXX 方法可以使用不同類型的數據覆蓋掉相同的 key泰佳。

val key = "jetpack" 
val sp = getSharedPreferences("ByteCode", Context.MODE_PRIVATE) 
sp.edit { putInt(key, 0) } // 使用 Int 類型的數據覆蓋相同的 key 
sp.getString(key, ""); // 使用相同的 key 讀取 Sting 類型的數據

由于 SP 內部是通過Map來保存對于的key-value盼砍,所以它并不能保證key-value的類型固定,導致通過get方法來獲取對應key的值的類型也是不安全的逝她。

getString的源碼中浇坐,會進行類型強制轉換,如果類型不對就會導致程序崩潰黔宛。由于SP不會在代碼編譯時進行提醒近刘,只能在代碼運行之后才能發(fā)現,避免不掉可能發(fā)生的異常臀晃。

SharedPreference 加載的數據會一直留在內存中觉渴,浪費內存

通過 getSharedPreferences() 方法加載的數據,最后會將數據存儲在靜態(tài)的成員變量中徽惋。靜態(tài)的 ArrayMap 緩存每一個 SP 文件案淋,而每個 SP 文件內容通過 Map 緩存鍵值對數據,這樣數據會一直留在內存中险绘,浪費內存哎迄。

apply() 方法雖然是異步的,仍可能會發(fā)生 ANR

apply 異步提交解決了線程的阻塞問題隆圆,但如果 apply 任務過多數據量過大,可能會導致ANR的產生翔烁。

apply() 方法不是異步的嗎渺氧,為什么還會造成 ANR 呢?apply() 方法本身沒有問題蹬屹,但是當生命周期處于 handleStopService() 侣背、 handlePauseActivity() 白华、 handleStopActivity() 的時候會一直等待 apply() 方法將數據保存成功,否則會一直等待贩耐,從而阻塞主線程造成 ANR弧腥。

public void apply() {
    final long startTime = System.currentTimeMillis();
 
    final MemoryCommitResult mcr = commitToMemory();
    final Runnable awaitCommit = new Runnable() {
            @Override
            public void run() {
                try {
                    mcr.writtenToDiskLatch.await();
                } catch (InterruptedException ignored) {
                }

                if (DEBUG && mcr.wasWritten) {
                    Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
                            + " applied after " + (System.currentTimeMillis() - startTime)
                            + " ms");
                }
            }
        };
 
    // 注意:將awaitCommit添加到隊列中
    QueuedWork.addFinisher(awaitCommit);
 
    Runnable postWriteRunnable = new Runnable() {
            @Override
            public void run() {
                awaitCommit.run();
                // 成功寫入磁盤之后才將awaitCommit移除
                QueuedWork.removeFinisher(awaitCommit);
            }
        };
 
    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
 
    // Okay to notify the listeners before it's hit disk
    // because the listeners should always get the same
    // SharedPreferences instance back, which has the
    // changes reflected in memory.
    notifyListeners(mcr);
}

這里關鍵點是會將 awaitCommit 加入到 QueuedWork 隊列中,只有當 awaitCommit 執(zhí)行完之后才會進行移除潮太。

另一方面管搪,在 ActivityServicehandleStopService()handlePauseActivity() 铡买、 handleStopActivity() 中會等待 QueuedWork 中的任務全部完成更鲁,一旦 QueuedWork 中的任務非常耗時,例如 SP 的寫入磁盤數據量過多奇钞,就會導致主線程長時間未響應澡为,從而產生 ANR:

public void handlePauseActivity(IBinder token, boolean finished, boolean userLeaving,
        int configChanges, PendingTransactionActions pendingActions, String reason) {
    ActivityClientRecord r = mActivities.get(token);
    if (r != null) {
        if (userLeaving) {
            performUserLeavingActivity(r);
        }
 
        r.activity.mConfigChangeFlags |= configChanges;
        performPauseActivity(r, finished, reason, pendingActions);

        // Make sure any pending writes are now committed.
        if (r.isPreHoneycomb()) {
            //等待任務完成
            QueuedWork.waitToFinish();
        }
        mSomeActivitiesChanged = true;
    }
}

SharedPreference 不能跨進程通信

SP 是不能跨進程通信的,雖然在獲取 SP 時提供了MODE_MULTI_PROCESS景埃,但內部并不是用來跨進程的媒至。

public SharedPreferences getSharedPreferences(File file, int mode) {
    if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
        getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
        // 重新讀取SP文件內容
        sp.startReloadIfChangedUnexpectedly();
    }
    return sp;
}

在這里使用 MODE_MULTI_PROCESS 只是重新讀取一遍文件而已,并不能保證跨進程通信谷徙。

apply() 方法沒有結果回調

為了防止 SP 寫入時阻塞線程拒啰,一般都會使用 apply 方法來將數據異步寫入到文件中,但它無法有返回值蒂胞,也沒有對應的結果回調图呢,所以無法得知此次寫入結果是成功還是失敗。

DataStore 有哪些改進

針對 SP 的幾個問題骗随,DataStore 都夠能規(guī)避蛤织。

  • DataStore 內部使用 kotlin 協程通過掛起的方式來避免阻塞線程,避免產生 ANR鸿染。
  • DataStore 不僅支持 SP 同時還支持 protocol buffers 類型的存儲指蚜,protocol buffers 是可以保證數據類型安全的。
  • DataStore 能夠在編譯階段提醒 SP 類型錯誤涨椒,減少寫代碼時的失誤導致類型不安全問題摊鸡。
  • DataStore 使用 Flow 來獲取數據,每次保存數據之后都會通知最近的 Flow蚕冬,可以獲得到操作成功或失敗的結果免猾。
  • DataStore 完美支持 SP 數據的遷移,可以無成本過渡到 DataStore囤热。

對比圖

SharedPreferences猎提、DataStoreMMKV 的對比:

DataStore 的使用和遷移

Preferences DataStore

添加依賴
implementation "androidx.datastore:datastore-preferences:1.0.0-alpha01" 
構建 DataStore
private val PREFERENCE_NAME = "DataStore"
var dataStore: DataStore<Preferences> = context.createDataStore(
    name = PREFERENCE_NAME

存儲位置為 data/data/包名/files/datastore/ + PREFERENCE_NAME + .preferences_pb

讀取數據

注意Preferences DataStore 只支持 Int , Long , Boolean , Float , String 這幾種鍵值對數據旁蔼。

val KEY_BYTE_CODE = preferencesKey<Boolean>("ByteCode")

fun readData(key: Preferences.Key<Boolean>): Flow<Boolean> =
    dataStore.data
        .map { preferences ->
            preferences[key] ?: false
        }

dataStore.data 會返回一個 Flow<T>锨苏,每當數據變化的時候都會重新發(fā)出疙教。

寫入數據
suspend fun saveData(key: Preferences.Key<Boolean>) {
    dataStore.edit { mutablePreferences ->
        val value = mutablePreferences[key] ?: false
        mutablePreferences[key] = !value
    }
}

通過 DataStore.edit() 寫入數據的,DataStore.edit() 是一個 suspend 函數伞租,所以只能在協程體內使用贞谓。

從 SharedPreferences 遷移

遷移 SharedPreferencesDataStore 只需要 2 步。

  • 構建 DataStore 的時候葵诈,需要傳入一個 SharedPreferencesMigration
dataStore = context.createDataStore(
    name = PREFERENCE_NAME,
    migrations = listOf(
        SharedPreferencesMigration(
            context,
            SharedPreferencesRepository.PREFERENCE_NAME
        )
    )
)
  • DataStore 對象構建完了之后裸弦,需要執(zhí)行一次讀取或者寫入操作,即可完成 SharedPreferences 遷移到 DataStore驯击,當遷移成功之后烁兰,會自動刪除 SharedPreferences 使用的文件。

注意: 只從 SharedPreferences 遷移一次徊都,因此一旦遷移成功之后沪斟,應該停止使用 SharedPreferences

Proto DataStore

Protocol Buffers:是 Google 開源的跨語言編碼協議暇矫,可以應用到 C++ 主之、C#Dart 李根、Go 槽奕、JavaPython 等等語言房轿,Google 內部幾乎所有 RPC 都在使用這個協議粤攒,使用了二進制編碼壓縮,體積更小囱持,速度比 JSON 更快夯接,但是缺點是犧牲了可讀性。

Proto DataStore 通過 protocol buffers 將對象序列化存儲在本地纷妆,比起 Preference DataStore 支持更多類型盔几,使用二進制編碼壓縮,體積更小速度更快掩幢。使用 Proto DataStore 需要先引入 protocol buffers逊拍。

本文只對 Proto DataStore 做簡單介紹。

添加依賴
// Proto DataStore
implementation "androidx.datastore:datastore-core:1.0.0-alpha01"
// protobuf
implementation "com.google.protobuf:protobuf-javalite:3.10.0"

當添加完依賴之后需要新建 proto 文件际邻,在本文示例項目中新建了一個 common-protobuf 模塊芯丧,將新建的 person.proto 文件,放到了 common-protobuf 模塊 src/main/proto 目錄下世曾。

common-protobuf 模塊注整,build.gradle 文件內,添加以下依賴:

implementation "com.google.protobuf:protobuf-javalite:3.10.0"
新建 Person.proto 文件,添加以下內容
syntax = "proto3";

option java_package = "com.hi.dhl.datastore.protobuf";
option java_outer_classname = "PersonProtos";

message Person {
    // 格式:字段類型 + 字段名稱 + 字段編號
    string name = 1;
}
執(zhí)行 protoc 肿轨,編譯 proto 文件
protoc --java_out=./src/main/java -I=./src/main/proto  ./src/main/proto/*.proto
構建 DataStore
object PersonSerializer : Serializer<PersonProtos.Person> {
    override fun readFrom(input: InputStream): PersonProtos.Person {
        try {
            return PersonProtos.Person.parseFrom(input) // 是編譯器自動生成的,用于讀取并解析 input 的消息
        } catch (exception: Exception) {
            throw CorruptionException("Cannot read proto.", exception)
        }
    }

    override fun writeTo(t: PersonProtos.Person, output: OutputStream) = t.writeTo(output) // t.writeTo(output) 是編譯器自動生成的蕊程,用于寫入序列化消息
}
讀取數據
fun readData(): Flow<PersonProtos.Person> {
    return protoDataStore.data
        .catch {
            if (it is IOException) {
                it.printStackTrace()
                emit(PersonProtos.Person.getDefaultInstance())
            } else {
                throw it
            }
        }
寫入數據
suspend fun saveData(personModel: PersonModel) {
    protoDataStore.updateData { person ->
        person.toBuilder().setAge(personModel.age).setName(personModel.name).build()
    }
}
SharedPreferences 遷移
  • 創(chuàng)建映射關系

  • 構建 DataStore 并傳入 shardPrefsMigration

  • 執(zhí)行一次讀取或者寫入操作

SuperApp引入

SuperApp 當前使用 SP 實現小數據存取椒袍,具體由 IPCConfig 工具類封裝 SP 提供靜態(tài)方法供各處使用。鑒于 DataStore 的各項改進及遷移非常方便藻茂,可以考慮從 SP 遷移到 DataStore驹暑。

Proto DataStore 雖然有更多優(yōu)勢,但需要引入Protocol Buffers辨赐,同時開發(fā)者需要如 proto 語法等更多的學習成本优俘,使用和遷移也會稍微麻煩些∠菩颍考慮到現在暫時沒有 Proto DataStore 對應的使用場景帆焕,可以先遷移到 Preferences DataStore,后續(xù)如有需要再做處理不恭。

初步改寫 IPCConfig

const val SHARED_PREFERENCES_NAME = "com.tplink.superapp_preferences"
const val DATA_STORE_NAME = "IPCConfig"

object IPCConfig {

    private var mDataStore: DataStore<Preferences>? = null

    @JvmStatic
    fun putBoolean(context: Context?, key: String?, flag: Boolean) {
        setConfig(context, key, flag)
    }

    @JvmStatic
    fun getBoolean(context: Context?, key: String?, defaultValue: Boolean): Boolean {
        return getConfig(context, key, defaultValue)
    }

    @JvmStatic
    fun putInt(context: Context?, key: String?, num: Int) {
        setConfig(context, key, num)
    }

    @JvmStatic
    fun getInt(context: Context?, key: String?, defaultValue: Int): Int {
        return getConfig(context, key, defaultValue)
    }

    @JvmStatic
    fun putString(context: Context?, key: String?, value: String) {
        setConfig(context, key, value)
    }

    @JvmStatic
    fun getString(context: Context?, key: String?, defaultValue: String): String {
        return getConfig(context, key, defaultValue)
    }

    @JvmStatic
    fun putLong(context: Context?, key: String?, value: Long) {
        setConfig(context, key, value)
    }

    @JvmStatic
    fun getLong(context: Context?, key: String?, defaultValue: Long): Long {
        return getConfig(context, key, defaultValue)
    }

    private fun getDataStore(context: Context): DataStore<Preferences>? {
        if (mDataStore == null) {
            mDataStore = context.createDataStore(
                name = DATA_STORE_NAME,
                migrations = listOf(
                    SharedPreferencesMigration(
                        context,
                        SHARED_PREFERENCES_NAME
                    )
                )
            )
        }
        return mDataStore
    }

    private inline fun <reified T : Any> getConfig(
        context: Context?,
        key: String?,
        defaultValue: T
    ): T {
        if (context == null || key == null) {
            return defaultValue
        }
        return runBlocking {
            getDataStore(context)?.data
                ?.catch {
                    // 當讀取數據遇到錯誤時叶雹,如果是IOException異常,發(fā)送一個emptyPreferences重新使用
                    // 但是如果是其他的異常换吧,最好將它拋出去折晦,不要隱藏問題
                    it.printStackTrace()
                    if (it is IOException) {
                        emit(emptyPreferences())
                    } else {
                        throw it
                    }
                }?.map {
                    it[preferencesKey<T>(key)] ?: defaultValue
                }?.first() ?: defaultValue
        }
    }

    private inline fun <reified T : Any> setConfig(context: Context?, key: String?, value: T) {
        if (context == null || key == null) {
            return
        }
        GlobalScope.launch {
            getDataStore(context)?.edit {
                it[preferencesKey<T>(key)] = value
            }
        }
    }
}

遷移前后文件結構:

測試可正常使用。

這樣修改可以只改變一個文件沾瓦,各調用處無需變動满着,就完成到 Preferences DataStore 的遷移,但是 get 方法都是 runBlocking 同步方法贯莺,沒有使用到 DataStore 的全部功能风喇。這里只是為了簡單驗證下遷移的可行性和便捷性,后續(xù)可以繼續(xù)優(yōu)化充分利用好 DataStore 的優(yōu)勢乖篷。

?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末响驴,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子撕蔼,更是在濱河造成了極大的恐慌豁鲤,老刑警劉巖,帶你破解...
    沈念sama閱讀 218,941評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件鲸沮,死亡現場離奇詭異琳骡,居然都是意外死亡,警方通過查閱死者的電腦和手機讼溺,發(fā)現死者居然都...
    沈念sama閱讀 93,397評論 3 395
  • 文/潘曉璐 我一進店門楣号,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事炫狱≡謇粒” “怎么了?”我有些...
    開封第一講書人閱讀 165,345評論 0 356
  • 文/不壞的土叔 我叫張陵视译,是天一觀的道長嬉荆。 經常有香客問我,道長酷含,這世上最難降的妖魔是什么鄙早? 我笑而不...
    開封第一講書人閱讀 58,851評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮椅亚,結果婚禮上限番,老公的妹妹穿的比我還像新娘。我一直安慰自己呀舔,他們只是感情好弥虐,可當我...
    茶點故事閱讀 67,868評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著别威,像睡著了一般躯舔。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上省古,一...
    開封第一講書人閱讀 51,688評論 1 305
  • 那天粥庄,我揣著相機與錄音,去河邊找鬼豺妓。 笑死惜互,一個胖子當著我的面吹牛,可吹牛的內容都是我干的琳拭。 我是一名探鬼主播训堆,決...
    沈念sama閱讀 40,414評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼白嘁!你這毒婦竟也來了坑鱼?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 39,319評論 0 276
  • 序言:老撾萬榮一對情侶失蹤絮缅,失蹤者是張志新(化名)和其女友劉穎鲁沥,沒想到半個月后,有當地人在樹林里發(fā)現了一具尸體耕魄,經...
    沈念sama閱讀 45,775評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡画恰,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 37,945評論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現自己被綠了吸奴。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片允扇。...
    茶點故事閱讀 40,096評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡缠局,死狀恐怖,靈堂內的尸體忽然破棺而出考润,到底是詐尸還是另有隱情狭园,我是刑警寧澤,帶...
    沈念sama閱讀 35,789評論 5 346
  • 正文 年R本政府宣布额划,位于F島的核電站妙啃,受9級特大地震影響,放射性物質發(fā)生泄漏俊戳。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,437評論 3 331
  • 文/蒙蒙 一馆匿、第九天 我趴在偏房一處隱蔽的房頂上張望抑胎。 院中可真熱鬧,春花似錦渐北、人聲如沸阿逃。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,993評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽恃锉。三九已至,卻和暖如春呕臂,著一層夾襖步出監(jiān)牢的瞬間破托,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,107評論 1 271
  • 我被黑心中介騙來泰國打工歧蒋, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留土砂,地道東北人。 一個月前我還...
    沈念sama閱讀 48,308評論 3 372
  • 正文 我出身青樓谜洽,卻偏偏與公主長得像萝映,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子阐虚,可洞房花燭夜當晚...
    茶點故事閱讀 45,037評論 2 355

推薦閱讀更多精彩內容

  • DataStore出現的原因 Jetpack DataStore is a data storage soluti...
    不做android閱讀 1,490評論 1 0
  • 今天感恩節(jié)哎序臂,感謝一直在我身邊的親朋好友。感恩相遇实束!感恩不離不棄奥秆。 中午開了第一次的黨會,身份的轉變要...
    迷月閃星情閱讀 10,567評論 0 11
  • 彩排完磕洪,天已黑
    劉凱書法閱讀 4,218評論 1 3
  • 沒事就多看看書吭练,因為腹有詩書氣自華,讀書萬卷始通神析显。沒事就多出去旅游鲫咽,別因為沒錢而找借口签赃,因為只要你省吃儉用,來...
    向陽之心閱讀 4,788評論 3 11
  • 表情是什么分尸,我認為表情就是表現出來的情緒锦聊。表情可以傳達很多信息。高興了當然就笑了箩绍,難過就哭了孔庭。兩者是相互影響密不可...
    Persistenc_6aea閱讀 125,096評論 2 7