<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          再見 SharedPreferences,你好Jetpack DataStore!

          共 24037字,需瀏覽 49分鐘

           ·

          2021-03-14 13:33


          Google 新增加了一個新 Jetpack 的成員 DataStore,主要用來替換 SharedPreferences, DataStore 應該是開發(fā)者期待已久的庫,DataStore 是基于 Flow 實現的,一種新的數據存儲方案,它提供了兩種實現方式:

          • Proto DataStore:存儲類的對象(typed objects ),通過 protocol buffers 將對象序列化存儲在本地,protocol buffers 現在已經應用的非常廣泛,無論是微信還是阿里等等大廠都在使用,我們在部分業(yè)務場景中也用到了 protocol buffers,會在后續(xù)的文章詳細分析

          • Preferences DataStore:以鍵值對的形式存儲在本地和 SharedPreferences 類似,但是 DataStore 是基于 Flow 實現的,不會阻塞主線程,并且保證類型安全


          Jetpack DataStore 將會分為至少 2 篇文章來分析,今天這篇文章主要來介紹 Jetpack DataStore 其中一種實現方式 Preferences DataStore。


          文章中的示例代碼,已經上傳到 GitHub 歡迎前去查看  AndroidX-Jetpack-Practice/DataStoreSimple

          GitHub 地址:

          https://github.com/hi-dhl/AndroidX-Jetpack-Practice


          這篇文章會涉及到 Koltin flow 相關內容,如果不了解可以先去看另外一篇文章 Kotlin Flow 是什么?Channel 是什么?


          通過這篇文章你將學習到以下內容:


          • 那些年我們所經歷的 SharedPreferences 坑?
          • 為什么需要 DataStore?它為我們解決了什么問題?
          • 如何在項目中使用 DataStore?
          • 如何遷移 SharedPreferences 到 DataStore?
          • MMKV、DataStore、SharedPreferences 的不同之處?


          一個新庫的出現必定為我們解決了一些問題,那么 Jetpack DataStore 為我們解決什么問題呢,在分析之前,我們需要先來了解 SharedPreferences 都有那些坑。

          1.那些年我們所經歷的 SharedPreferences 坑


          SharedPreference 是一個輕量級的數據存儲方式,使用起來也非常方便,以鍵值對的形式存儲在本地,初始化 SharedPreference 的時候,會將整個文件內容加載內存中,因此會帶來以下問題:


          • 通過 getXXX() 方法獲取數據,可能會導致主線程阻塞
          • SharedPreference 不能保證類型安全
          • SharedPreference 加載的數據會一直留在內存中,浪費內存
          • apply() 方法雖然是異步的,可能會發(fā)生 ANR,在 8.0 之前和 8.0 之后實現各不相同
          • apply() 方法無法獲取到操作成功或者失敗的結果


          接下來我們逐個來分析一下 SharedPreferences 帶來的這些問題,在文章中 SharedPreference 簡稱 SP。


          getXXX() 方法可能會導致主線程阻塞


          所有 getXXX() 方法都是同步的,在主線程調用 get 方法,必須等待 SP 加載完畢,會導致主線程阻塞,下面的代碼,我相信小伙伴們并不陌生。


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

          調用 getSharedPreferences() 方法,最終會調用  SharedPreferencesImpl#startLoadFromDisk() 方法開啟一個線程異步讀取數據。


          frameworks/base/core/java/android/app/SharedPreferencesImpl.java


          private final Object mLock = new Object();
          private boolean mLoaded = false;
          private void startLoadFromDisk() {
              synchronized (mLock) {
                  mLoaded = false;
              }
              new Thread("SharedPreferencesImpl-load") {
                  public void run() {
                      loadFromDisk();
                  }
              }.start();
          }

          正如你所看到的,開啟一個線程異步讀取數據,當我們正在讀取一個比較大的數據,還沒讀取完,接著調用 getXXX() 方法。


          public String getString(String key, @Nullable String defValue) {
              synchronized (mLock) {
                  awaitLoadedLocked();
                  String v = (String)mMap.get(key);
                  return v != null ? v : defValue;
              }
          }

          private void awaitLoadedLocked() {
              ......
              while (!mLoaded) {
                  try {
                      mLock.wait();
                  } catch (InterruptedException unused) {
                  }
              }
              ......
          }

          在同步方法內調用了 wait() 方法,會一直等待 getSharedPreferences() 方法開啟的線程讀取完數據才能繼續(xù)往下執(zhí)行,如果讀取幾 KB 的數據還好,假設讀取一個大的文件,勢必會造成主線程阻塞。


          SP 不能保證類型安全


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


          val key = "jetpack"
          val sp = getSharedPreferences("ByteCode", Context.MODE_PRIVATE) // 異步加載 SP 文件內容

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

          使用 Int 類型的數據覆蓋掉相同的 key,然后使用相同的 key 讀取 Sting 類型的數據,編譯正常,但是運行會出現以下異常。


          java.lang.ClassCastExceptionjava.lang.Integer cannot be cast to java.lang.String

          SP 加載的數據會一直留在內存中


          通過 getSharedPreferences() 方法加載的數據,最后會將數據存儲在靜態(tài)的成員變量中。


          // 調用 getSharedPreferences 方法,最后會調用 getSharedPreferencesCacheLocked 方法
          public SharedPreferences getSharedPreferences(File file, int mode{
              ......
              final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
              return sp;
          }

          // 通過靜態(tài)的 ArrayMap 緩存 SP 加載的數據
          private static ArrayMap<String, ArrayMap<File, SharedPreferencesImpl>> sSharedPrefsCache;

          // 將數據保存在 sSharedPrefsCache 中
          private ArrayMap<File, SharedPreferencesImpl> getSharedPreferencesCacheLocked() {
              ......

              ArrayMap<File, SharedPreferencesImpl> packagePrefs = sSharedPrefsCache.get(packageName);
              if (packagePrefs == null) {
                  packagePrefs = new ArrayMap<>();
                  sSharedPrefsCache.put(packageName, packagePrefs);
              }

              return packagePrefs;
          }

          通過靜態(tài)的 ArrayMap 緩存每一個 SP 文件,而每個 SP 文件內容通過 Map 緩存鍵值對數據,這樣數據會一直留在內存中,浪費內存。


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


          apply() 方法是異步的,為什么還會造成 ANR 呢?曾今的字節(jié)跳動就出現過這個問題,具體詳情可以點擊這里前去查看 剖析 SharedPreference apply 引起的 ANR 問題 而且 Google 也明確指出了 apply() 的問題。



          簡單總結一下:apply() 方法是異步的,本身是不會有任何問題,但是當生命周期處于  handleStopService() 、 handlePauseActivity() 、 handleStopActivity()  的時候會一直等待 apply() 方法將數據保存成功,否則會一直等待,從而阻塞主線程造成 ANR,一起來分析一下為什么異步方法還會阻塞主線程,先來看看 apply() 方法的實現。


          frameworks/base/core/java/android/app/SharedPreferencesImpl.java


          public void apply() {
              final long startTime = System.currentTimeMillis();

              final MemoryCommitResult mcr = commitToMemory();
              final Runnable awaitCommit = new Runnable() {
                      @Override
                      public void run() {
                          mcr.writtenToDiskLatch.await(); // 等待
                          ......
                      }
                  };
              // 將 awaitCommit 添加到隊列 QueuedWork 中
              QueuedWork.addFinisher(awaitCommit);

              Runnable postWriteRunnable = new Runnable() {
                      @Override
                      public void run() {
                          awaitCommit.run();
                          QueuedWork.removeFinisher(awaitCommit);
                      }
                  };
              // 8.0 之前加入到一個單線程的線程池中執(zhí)行
              // 8.0 之后加入 HandlerThread 中執(zhí)行寫入任務
              SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
          }

          • 將一個 awaitCommit 的  Runnable 任務,添加到隊列 QueuedWork 中,在 awaitCommit 中會調用 await() 方法等待,在 handleStopService 、 handleStopActivity 等等生命周期會以這個作為判斷條件,等待任務執(zhí)行完畢
          • 將一個 postWriteRunnable 的  Runnable 寫任務,通過 enqueueDiskWrite 方法,將寫入任務加入到隊列中,而寫入任務在一個線程中執(zhí)行


          注意:在 8.0 之前和 8.0 之后 enqueueDiskWrite() 方法實現邏輯各不相同。


          在 8.0 之前調用 enqueueDiskWrite() 方法,將寫入任務加入到 單個線程的線程池 中執(zhí)行,如果 apply() 多次的話,任務將會依次執(zhí)行,效率很低,android-7.0.0_r34 源碼如下所示。


          // android-7.0.0_r34: frameworks/base/core/java/android/app/SharedPreferencesImpl.java
          private void enqueueDiskWrite(final MemoryCommitResult mcr,
                                        final Runnable postWriteRunnable)
           
          {
              ......
              QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable);
          }

          // android-7.0.0_r34: frameworks/base/core/java/android/app/QueuedWork.java
          public static ExecutorService singleThreadExecutor() {
              synchronized (QueuedWork.class) {
                  if (sSingleThreadExecutor == null) {
                      sSingleThreadExecutor = Executors.newSingleThreadExecutor();
                  }
                  return sSingleThreadExecutor;
              }
          }

          通過 Executors.newSingleThreadExecutor() 方法創(chuàng)建了一個 單個線程的線程池,因此任務是串行的,通過 apply() 方法創(chuàng)建的任務,都會添加到這個線程池內。


          在 8.0 之后將寫入任務加入到 LinkedList 鏈表中,在 HandlerThread 中執(zhí)行寫入任務,android-10.0.0_r14 源碼如下所示。


          // android-10.0.0_r14: frameworks/base/core/java/android/app/SharedPreferencesImpl.java
          private void enqueueDiskWrite(final MemoryCommitResult mcr,
                                        final Runnable postWriteRunnable)
           
          {
              ......
              QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
          }

          // android-10.0.0_r14: frameworks/base/core/java/android/app/QueuedWork.java

          private static final LinkedList<Runnable> sWork = new LinkedList<>();

          public static void queue(Runnable work, boolean shouldDelay) {
              Handler handler = getHandler(); // 獲取 handlerThread.getLooper() 生成 Handler 對象
              synchronized (sLock) {
                  sWork.add(work); // 將寫入任務加入到 LinkedList 鏈表中

                  if (shouldDelay && sCanDelay) {
                      handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY);
                  } else {
                      handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);
                  }
              }
          }

          在 8.0 之后通過調用 handlerThread.getLooper() 方法生成 Handler,任務都會在 HandlerThread 中執(zhí)行,所有通過 apply() 方法創(chuàng)建的任務,都會添加到 LinkedList 鏈表中。


          當生命周期處于 handleStopService() 、 handlePauseActivity() 、 handleStopActivity() 的時候會調用 QueuedWork.waitToFinish() 會等待寫入任務執(zhí)行完畢,我們以其中  handlePauseActivity() 方法為例。


          public void handlePauseActivity(IBinder token, boolean finished, boolean userLeaving,
                  int configChanges, PendingTransactionActions pendingActions, String reason)
           
          {
                  ......
                  // 確保寫任務都已經完成
                  QueuedWork.waitToFinish();
                  ......
              }
          }

          正如你所看到的在 handlePauseActivity() 方法中,調用了 QueuedWork.waitToFinish() 方法,會等待所有的寫入執(zhí)行完畢,Google 在 8.0 之后對這個方法做了很大的優(yōu)化,一起來看一下 8.0 之前和 8.0 之后的區(qū)別。


          注意:在 8.0 之前和 8.0 之后 waitToFinish() 方法實現邏輯各不相同


          在 8.0 之前 waitToFinish() 方法只做了一件事,會一直等待寫入任務執(zhí)行完畢,我先來看看在 android-7.0.0_r34 源碼實現。


          android-7.0.0_r34: frameworks/base/core/java/android/app/QueuedWork.java


          private static final ConcurrentLinkedQueue<Runnable> sPendingWorkFinishers =
                  new ConcurrentLinkedQueue<Runnable>();

          public static void waitToFinish() {
              Runnable toFinish;
              while ((toFinish = sPendingWorkFinishers.poll()) != null) {
                  toFinish.run(); // 相當于調用 `mcr.writtenToDiskLatch.await()` 方法
              }
          }

          • sPendingWorkFinishers 是 ConcurrentLinkedQueue 實例,apply 方法會將寫入任務添加到 sPendingWorkFinishers 隊列中,在 單個線程的線程池 中執(zhí)行寫入任務,線程的調度并不由程序來控制,也就是說當生命周期切換的時候,任務不一定處于執(zhí)行狀態(tài)

          • toFinish.run() 方法,相當于調用 mcr.writtenToDiskLatch.await() 方法,會一直等待

          • waitToFinish() 方法就做了一件事,會一直等待寫入任務執(zhí)行完畢,其它什么都不做,當有很多寫入任務,會依次執(zhí)行,當文件很大時,效率很低,造成 ANR 就不奇怪了,尤其像字節(jié)跳動這種大規(guī)模的 App


          在 8.0 之后 waitToFinish() 方法做了很大的優(yōu)化,當生命周期切換的時候,會主動觸發(fā)任務的執(zhí)行,而不是一直在等著,我們來看看 android-10.0.0_r14 源碼實現。


          android-10.0.0_r14: frameworks/base/core/java/android/app/QueuedWork.java


          private static final LinkedList<Runnable> sFinishers = new LinkedList<>();
          public static void waitToFinish() {
              ......
              try {
                  processPendingWork(); // 主動觸發(fā)任務的執(zhí)行
              } finally {
                  StrictMode.setThreadPolicy(oldPolicy);
              }

              try {
                  // 等待任務執(zhí)行完畢
                  while (true) {
                      Runnable finisher;

                      synchronized (sLock) {
                          finisher = sFinishers.poll(); // 從 LinkedList 中取出任務
                      }

                      if (finisher == null) { // 當 LinkedList 中沒有任務時會跳出循環(huán)
                          break;
                      }

                      finisher.run(); // 相當于調用 `mcr.writtenToDiskLatch.await()`
                  }
              } 

              ......
          }


          在 waitToFinish() 方法中會主動調用 processPendingWork() 方法觸發(fā)任務的執(zhí)行,在 HandlerThread 中執(zhí)行寫入任務。


          另外還做了一個很重要的優(yōu)化,當調用 apply() 方法的時候,執(zhí)行磁盤寫入,都是全量寫入,在 8.0 之前,調用 N 次 apply() 方法,就會執(zhí)行 N 次磁盤寫入,在 8.0 之后,apply() 方法調用了多次,只會執(zhí)行最后一次寫入,通過版本號來控制的。


          SharedPreferences 的另外一個缺點就是 apply() 方法無法獲取到操作成功或者失敗的結果,而 commit() 方法是可以接收 MemoryCommitResult 里面的一個 boolean 參數作為結果,來看一下它們的方法簽名。


          public void apply() { ... }

          public boolean commit() { ... }

          SP 不能用于跨進程通信


          我們在創(chuàng)建 SP 實例的時候,需要傳入一個 mode,如下所示:


          val sp = getSharedPreferences("ByteCode", Context.MODE_PRIVATE) 

          Context 內部還有一個 mode 是 MODE_MULTI_PROCESS,我們來看一下這個 mode 做了什么


          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 的時候,會重新讀取 SP 文件內容,并不能用 SP 來做跨進程通信。


          到這里關于 SharedPreferences 部分分析完了,接下來分析一下 DataStore 為我們解決什么問題?

          2.DataStore  解決了什么問題

          Preferences DataStore 主要用來替換 SharedPreferences,Preferences DataStore 解決了 SharedPreferences 帶來的所有問題。


          Preferences DataStore 相比于 SharedPreferences 優(yōu)點


          • DataStore 是基于 Flow 實現的,所以保證了在主線程的安全性
          • 以事務方式處理更新數據,事務有四大特性(原子性、一致性、 隔離性、持久性)
          • 沒有 apply() 和 commit() 等等數據持久的方法
          • 自動完成 SharedPreferences 遷移到 DataStore,保證數據一致性,不會造成數據損壞
          • 可以監(jiān)聽到操作成功或者失敗結果


          另外 Jetpack DataStore 提供了 Proto DataStore 方式,用于存儲類的對象(typed objects ),通過 protocol buffers 將對象序列化存儲在本地,protocol buffers 現在已經應用的非常廣泛,無論是微信還是阿里等等大廠都在使用,我們在部分場景中也使用了 protocol buffers,在后續(xù)的文章會詳細的分析。


          注意:


          Preferences DataStore 只支持 Int , Long , Boolean , Float , String 鍵值對數據,適合存儲簡單、小型的數據,并且不支持局部更新,如果修改了其中一個值,整個文件內容將會被重新序列化,可以運行 AndroidX-Jetpack-Practice/DataStoreSimple 體驗一下,如果需要局部更新,建議使用 Room。

          3.在項目中使用 Preferences DataStore

          Preferences DataStore 主要應用在 MVVM 當中的 Repository 層,在項目中使用 Preferences DataStore 非常簡單,只需要 4 步。


          1. 需要添加 Preferences DataStore 依賴


          implementation "androidx.datastore:datastore-preferences:1.0.0-alpha01"

          2. 構建 DataStore


          private val PREFERENCE_NAME = "DataStore"
          var dataStore: DataStore<Preferences> = context.createDataStore(
              name = PREFERENCE_NAME


          3. 從 Preferences DataStore 中讀取數據


          Preferences DataStore 以鍵值對的形式存儲在本地,所以首先我們應該定義一個 Key.


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

          這里和我們之前使用 SharedPreferences 的有點不一樣,在 Preferences DataStore 中 Key 是一個 Preferences.Key<T> 類型,只支持 Int , Long , Boolean , Float , String,源碼如下所示:


          inline fun <reified T : Any> preferencesKey(name: String): Preferences.Key<T> {
              return when (T::class{
                  Int::class -> {
                      Preferences.Key<T>(name)
                  }
                  String::class -> {
                      Preferences.Key<T>(name)
                  }
                  Boolean::class -> {
                      Preferences.Key<T>(name)
                  }
                  Float::class -> {
                      Preferences.Key<T>(name)
                  }
                  Long::class -> {
                      Preferences.Key<T>(name)
                  }
                  ...... // 如果是其他類型就會拋出異常
              }
          }

          當我們定義好 Key 之后,就可以通過 dataStore.data 來獲取數據


          override fun readData(key: Preferences.Key<Boolean>): Flow<Boolean> =
              dataStore.data
                  .catch {
                      // 當讀取數據遇到錯誤時,如果是 `IOException` 異常,發(fā)送一個 emptyPreferences 來重新使用
                      // 但是如果是其他的異常,最好將它拋出去,不要隱藏問題
                      if (it is IOException) {
                          it.printStackTrace()
                          emit(emptyPreferences())
                      } else {
                          throw it
                      }
                  }.map { preferences ->
                      preferences[key] ?: false
                  }

          • Preferences DataStore 是基于 Flow 實現的,所以通過 dataStore.data 會返回一個 Flow<T>,每當數據變化的時候都會重新發(fā)出
          • catch 用來捕獲異常,當讀取數據出現異常時會拋出一個異常,如果是 IOException 異常,會發(fā)送一個 emptyPreferences() 來重新使用,如果是其他異常,最好將它拋出去


          4. 向 Preferences DataStore 中寫入數據


          在 Preferences DataStore 中是通過 DataStore.edit() 寫入數據的,DataStore.edit() 是一個 suspend 函數,所以只能在協(xié)程體內使用,每當遇到 suspend 函數以掛起的方式運行,并不會阻塞主線程。


          以掛起的方式運行,不會阻塞主線程 :也就是協(xié)程作用域被掛起, 當前線程中協(xié)程作用域之外的代碼不會阻塞。


          首先我們需要創(chuàng)建一個 suspend 函數,然后調用 DataStore.edit() 寫入數據即可。


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

          到這里關于 Preferences DataStore 讀取數據和寫入數據就已經分析完了,接下來分析一下如何遷移 SharedPreferences 到 DataStore。

          4.遷移 SharedPreferences 到 DataStore

          遷移 SharedPreferences 到 DataStore 只需要 2 步。


          在構建 DataStore 的時候,需要傳入一個 SharedPreferencesMigration

          dataStore = context.createDataStore(
              name = PREFERENCE_NAME,
              migrations = listOf(
                  SharedPreferencesMigration(
                      context,
                      SharedPreferencesRepository.PREFERENCE_NAME
                  )
              )
          )

          當 DataStore 對象構建完了之后,需要執(zhí)行一次讀取或者寫入操作,即可完成 SharedPreferences 遷移到  DataStore,當遷移成功之后,會自動刪除 SharedPreferences 使用的文件


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

          5.相比于 MMKV 有什么不同之處

          最后用一張表格來對比一下 MMKV、DataStore、SharedPreferences 的不同之處,如果發(fā)現錯誤,或者有其他不同之處,期待你來一起完善。



          另外在附上一張 Google 分析的 SharedPreferences 和 DataStore 的區(qū)別



          全文到這里就結束了,這篇文章主要分析了 SharedPreferences 和 DataStore 的優(yōu)缺點,以及為什么需要引入 DataStore 和如何使用 DataStore,為了節(jié)省篇幅源碼分析部分會在后續(xù)的文章中分析。


          關于 SharedPreferences 和 DataStore 相關的代碼,已經上傳到了 GitHub 歡迎前去查看 AndroidX-Jetpack-Practice/DataStoreSimple ,可以運行一下示例項目,體驗一下  SharedPreferences 和 DataStore 效果。


          GitHub 地址:
          https://github.com/hi-dhl/AndroidX-Jetpack-Practice

          參考文獻


          https://codelabs.developers.google.com/codelabs/android-preferences-datastore
          https://medium.com/androiddevelopers/now-in-android-25-8596a08554d7
          https://android-developers.googleblog.com/2020/09/prefer-storing-data-with-jetpack.html
          https://mp.weixin.qq.com/s?__biz=MzI1MzYzMjE0MQ==&mid=2247484387&idx=1&sn=e3c8d6ef52520c51b5e07306d9750e70&scene=21#wechat_redirect
          https://www.jianshu.com/p/3f64caa567e5


          ·················END·················

          推薦閱讀

          ? 耗時2年,Android進階三部曲第三部《Android進階指北》出版!

          ? 『BATcoder』做了多年安卓還沒編譯過源碼?一個視頻帶你玩轉!

          ? 『BATcoder』是時候下載Android11系統(tǒng)源碼和內核源碼了!

          推薦我的技術博客

          推薦一下我的獨立博客: liuwangshu.cn ,內含Android最強原創(chuàng)知識體系,一直在更新,歡迎體驗和收藏!

          BATcoder技術群,讓一部分人先進大廠

          你好,我是劉望舒,百度百科收錄的騰訊云TVP專家,著有暢銷書《Android進階之光》《Android進階解密》《Android進階指北》,蟬聯四屆電子工業(yè)出版社年度優(yōu)秀作者,谷歌開發(fā)者社區(qū)特邀講師。

          前華為面試官,現大廠技術負責人。


          歡迎添加我的微信 henglimogan ,備注:BATcoder,加入BATcoder技術群。



          明天見(??ω??)

          瀏覽 50
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  97成人资源站 | 一起草成人视频 | 九九九九九九九精品 | 国产精品怕怕怕 | 大香蕉视频在线精品 |