LeakCanary源码分析以及ContentProvider的优化方案

·  阅读 165

公众号:【Android老皮】 希望写出的东西能对你起到帮助 🤣🤣

1.使用 LeakCancary 2.0使用,只需要配置如下代码,便可以进行使用,比LeakCanary1.0不知道高到哪里去了~

debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.0-alpha-2'
复制代码

2.源码分析 阅读源码后可以看到leakcancary-leaksentry模块的Androidmanifest文件,可以看到下面的内容:

<?xml version="1.0" encoding="utf-8"?>
<manifest
    xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.squareup.leakcanary.leaksentry"
    >
​
  <application>
    <provider
        android:name="leakcanary.internal.LeakSentryInstaller"
        android:authorities="${applicationId}.leak-sentry-installer"
        android:exported="false"/>
  </application>
</manifest>
复制代码

然后我们可以看到LeakSentryInstaller这个类到底做了什么

internal class LeakSentryInstaller : ContentProvider() {
​
  override fun onCreate(): Boolean {
    CanaryLog.logger = DefaultCanaryLog()
    val application = context!!.applicationContext as Application
    //利用系统自动调用ContentProvider的onCreate来进行安装
    InternalLeakSentry.install(application)
    return true
  }
  ...
复制代码

至于为什么系统会调用ContentProvider的onCreate方法,我们可以看看源码,在ActivityThread中的H中的handleMessage可以看到

public void handleMessage(Message msg) {
    if (DEBUG_MESSAGES) Slog.v(TAG, ">>> handling: " + codeToString(msg.what));
    switch (msg.what) {
        case BIND_APPLICATION:
            Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "bindApplication");
            AppBindData data = (AppBindData)msg.obj;
            //关键方法
            handleBindApplication(data);
            Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
            break;
复制代码

然后在handleBindApplication中可以看到

// don't bring up providers in restricted mode; they may depend on the
// app's custom Application class
if (!data.restrictedBackupMode) {
    if (!ArrayUtils.isEmpty(data.providers)) {
        //contentprovider初始化,里面会调用onCreate方法
        installContentProviders(app, data.providers);
    }
}
​
// Do this after providers, since instrumentation tests generally start their
// test thread at this point, and we don't want that racing.
try {
    mInstrumentation.onCreate(data.instrumentationArgs);
}
catch (Exception e) {
    throw new RuntimeException(
        "Exception thrown in onCreate() of "
        + data.instrumentationName + ": " + e.toString(), e);
}
try {
    //app的onCreate方法调用
    mInstrumentation.callApplicationOnCreate(app);
} catch (Exception e) {
复制代码

具体调用contentprovider的onCreate代码逻辑如下

@UnsupportedAppUsage
private void installContentProviders(
        Context context, List<ProviderInfo> providers) {
    final ArrayList<ContentProviderHolder> results = new ArrayList<>();
​
    for (ProviderInfo cpi : providers) {
        ···
        //installProvider方法
        ContentProviderHolder cph = installProvider(context, null, cpi,
                false /*noisy*/, true /*noReleaseNeeded*/, true /*stable*/);
        if (cph != null) {
            cph.noReleaseNeeded = true;
            results.add(cph);
        }
    }
  //installProvider方法,然后一步步跟进
  //1
  //XXX Need to create the correct context for this provider.
  localProvider.attachInfo(c, info);
  //2
    public void attachInfo(Context context, ProviderInfo info) {
        attachInfo(context, info, false);
   }
  //3
  private void attachInfo(Context context, ProviderInfo info, boolean testing) {
        mNoPerms = testing;
        mCallingPackage = new ThreadLocal<>();
        if (mContext == null) {
            ···
            ContentProvider.this.onCreate();
        }
    }
复制代码

通过上面的分析,可以知道在我们引入依赖后,依赖包中的AndroidMainfest.xml文件便会主动合并到主AndroidManifest.xml文件中,然后在程序启动过程中便会自动创建ContentProvider,然后进行InternalLeakSentry.install(application),接下来进行一些列的监控和dump操作等。

2.1 InternalLeakSentry.install(application)

下面来分析InternalLeakSentry.install(application)里面都做了一些什么,可以看到

fun install(application: Application) {
    CanaryLog.d("Installing LeakSentry")
    checkMainThread()
    if (this::application.isInitialized) {
      return
    }
    InternalLeakSentry.application = application
​
    val configProvider = { LeakSentry.config }
    // 1.监听 Activity.onDestroy()
    ActivityDestroyWatcher.install(
        application, refWatcher, configProvider
    )
    // 2.监听 Fragment.onDestroy()
    FragmentDestroyWatcher.install(
        application, refWatcher, configProvider
    )
    // 3.监听完成后进行一些初始化工作
    listener.onLeakSentryInstalled(application)
  }
复制代码

从命名上可以看到在Activity和Fragment进行destory的时候进行watch

  1. ActivityDestroyWatcher
internal class ActivityDestroyWatcher private constructor(
  private val refWatcher: RefWatcher,
  private val configProvider: () -> Config
) {
​
  private val lifecycleCallbacks = object : ActivityLifecycleCallbacksAdapter() {
    override fun onActivityDestroyed(activity: Activity) {
      if (configProvider().watchActivities) {
        // 监听到 onDestroy() 之后,通过 refWatcher 监测 Activity
        refWatcher.watch(activity)
      }
    }
  }
​
  companion object {
    fun install(
      application: Application,
      refWatcher: RefWatcher,
      configProvider: () -> Config
    ) {
      val activityDestroyWatcher =
        ActivityDestroyWatcher(refWatcher, configProvider)
      application.registerActivityLifecycleCallbacks(activityDestroyWatcher.lifecycleCallbacks)
    }
  }
}
复制代码
  1. FragmentDestroyWatcher
internal interface FragmentDestroyWatcher {
​
  fun watchFragments(activity: Activity)
​
  companion object {
​
    private const val SUPPORT_FRAGMENT_CLASS_NAME = "androidx.fragment.app.Fragment"
​
    fun install(
      application: Application,
      refWatcher: RefWatcher,
      configProvider: () -> LeakSentry.Config
    ) {
      val fragmentDestroyWatchers = mutableListOf<FragmentDestroyWatcher>()
​
      //大于等于android O  
      if (SDK_INT >= O) {
        fragmentDestroyWatchers.add(
            AndroidOFragmentDestroyWatcher(refWatcher, configProvider)
        )
      }
​
      if (classAvailable(
              SUPPORT_FRAGMENT_CLASS_NAME
          )
      ) {
        // androidx 使用 SupportFragmentDestroyWatcher
        fragmentDestroyWatchers.add(
            SupportFragmentDestroyWatcher(refWatcher, configProvider)
        )
      }
​
      if (fragmentDestroyWatchers.size == 0) {
        return
      }
​
      application.registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacksAdapter() {
        override fun onActivityCreated(
          activity: Activity,
          savedInstanceState: Bundle?
        ) {
          for (watcher in fragmentDestroyWatchers) {
            watcher.watchFragments(activity)
          }
        }
      })
    }
​
    private fun classAvailable(className: String): Boolean {
      return try {
        Class.forName(className)
        true
      } catch (e: ClassNotFoundException) {
        false
      }
    }
  }
}
复制代码

Android O 及以后,androidx 都具备对 Fragment 生命周期的监听功能。为什么不监听Android O之前的呢???(待解决) 在版本为1.5.4之前是不支持Fragment内存泄漏监听的,后面版本才加了进来。

  1. listener.onLeakSentryInstalled(application)

该listener的最终实现类是leakcanary-android-core中的InternalLeakCanary类

override fun onLeakSentryInstalled(application: Application) {
    this.application = application
​
    val heapDumper = AndroidHeapDumper(application, leakDirectoryProvider)
        //用于发现可能的内存泄漏之后手动调用 GC 确认是否真的为内存泄露
    val gcTrigger = GcTrigger.Default
​
    val configProvider = { LeakCanary.config }
​
    val handlerThread = HandlerThread(HeapDumpTrigger.LEAK_CANARY_THREAD_NAME)
    handlerThread.start()
    val backgroundHandler = Handler(handlerThread.looper)
        //用于确认内存泄漏之后进行 heap dump 工作。
    heapDumpTrigger = HeapDumpTrigger(
        application, backgroundHandler, LeakSentry.refWatcher, gcTrigger, heapDumper, configProvider
    )
    application.registerVisibilityListener { applicationVisible ->
      this.applicationVisible = applicationVisible
      heapDumpTrigger.onApplicationVisibilityChanged(applicationVisible)
    }
    addDynamicShortcut(application)
  }
复制代码

这里有个关于GC回收的知识点,我们可以看看优秀的第三方框架都是怎么写的

interface GcTrigger {
  fun runGc()
  object Default : GcTrigger {
    override fun runGc() {
      // Code taken from AOSP FinalizationTest:
      // https://android.googlesource.com/platform/libcore/+/master/support/src/test/java/libcore/
      // java/lang/ref/FinalizationTester.java
      // System.gc() does not garbage collect every time. Runtime.gc() is
      // more likely to perform a gc.
      Runtime.getRuntime()
          .gc()
      enqueueReferences()
      System.runFinalization()
    }
    private fun enqueueReferences() {
      // Hack. We don't have a programmatic way to wait for the reference queue daemon to move
      // references to the appropriate queues.
      try {
        Thread.sleep(100)
      } catch (e: InterruptedException) {
        throw AssertionError()
      }
    }
  }
}
复制代码

可以看到,它使用了Runtime.getRuntime().gc()而不是System.gc(),进入System.gc源码一看

public static void gc() {
    boolean shouldRunGC;
    synchronized (LOCK) {
        shouldRunGC = justRanFinalization;
        if (shouldRunGC) {
            justRanFinalization = false;
        } else {
            runGC = true;
        }
    }
    if (shouldRunGC) {
        Runtime.getRuntime().gc();
    }
}
复制代码

可以看到System.gc源码的还是最终实现是Runtime.getRuntime().gc();但是需要一系列的判断条件,我们手动调用System.runFinalization()可以使gc方法中的justRanFinalizationw为true,从而保证Runtime.getRuntime().gc()会被执行。

####3.如何判断对象可能泄露:ReferenceQueue含义及作用

在Activity/Fragment销毁后,会进行一系列的对象回收,我们把这些对象分别和引用队列进行关联,当某个对象被回收时, (弱引用一旦变成弱可达(可达性算法分析),引用就会加到引用队列中,然后再进行回收) 我们对象的引用就会被加入到引用队列中。根据该原理进行一系列的操作,最终判断是否内存泄漏。

3.1 引用队列

通常我们将其ReferenceQueue翻译为引用队列,换言之就是存放引用的队列,保存的是Reference对象。其作用在于Reference对象所引用的对象被GC回收时,该Reference对象将会被加入引用队列中(ReferenceQueue)的队列末尾。

ReferenceQueue常用的方法:

public Reference poll():从队列中取出一个元素,队列为空则返回null;

public Reference remove():从队列中出对一个元素,若没有则阻塞至有可出队元素;

public Reference remove(long timeout):从队列中出对一个元素,若没有则阻塞至有可出对元素或阻塞至超过timeout毫秒;

  1. 强引用

  2. 软引用

  3. 弱引用

  4. 虚引用(Phantom Reference)

    虚引等同于没有引用,这意味着在任何时候都可能被GC回收,设置虚引用的目的是为了被虚引用关联的对象在被垃圾回收器回收时,能够收到一个系统通知。(被用来跟踪对象被GC回收的活动)虚引用和弱引用的区别在于:虚引用在使用时必须和引用队列(ReferenceQueue)联合使用,其在GC回收期间的活动如下:

    ReferenceQueue queue=new ReferenceQueue();

    PhantomReference pr=new PhantomReference(object,queue);

    也即是GC在回收一个对象时,如果发现该对象具有虚引用,那么在回收之前会首先该对象的虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入虚引用来了解被引用的对象是否被GC回收。

3.2 GC Root对象
3.3 内存是否泄漏

知道引用队列的原理后,先大概描述一下如何判断是否泄漏,首先创建三个队列

  /**
   * References passed to [watch] that haven't made it to [retainedReferences] yet.
   * watch() 方法传进来的引用,尚未判定为泄露
   */
  private val watchedReferences = mutableMapOf<String, KeyedWeakReference>()
  /**
   * References passed to [watch] that we have determined to be retained longer than they should
   * have been.
   * watch() 方法传进来的引用,已经被判定为泄露
   */
  private val retainedReferences = mutableMapOf<String, KeyedWeakReference>()
  private val queue = ReferenceQueue<Any>() // 引用队列,配合弱引用使用
    
//KeyedWeakReference,对象和引用队列进行弱引用关联,所以这个对象一定会被回收    
class KeyedWeakReference(
  referent: Any,
  val key: String,
  val name: String,
  val watchUptimeMillis: Long,
  referenceQueue: ReferenceQueue<Any>
) : WeakReference<Any>(
    referent, referenceQueue
) {
  @Volatile
  var retainedUptimeMillis = -1L
​
  companion object {
    @Volatile
    @JvmStatic var heapDumpUptimeMillis = 0L
  }
​
}    
复制代码

如果一个obj对象,它和队列queue进行弱引用关联,在进行垃圾收集时,发现该对象具有弱引用,会把引用加入到引用队列中,我们如果在该队列中拿到引用,则说明该对象被回收了,如果拿不到,则说明该对象还有强/软引用未释放,那么就说明对象还未回收,发生内存泄漏了,然后dump内存快照,使用第三方库进行引用链分析

这里重点强调一点一个对象可能被多个引用持有,比如强引用,软引用,弱引用,只要这个对象还有强引用/软引用,与这个对象关联的任意引用队列就拿不到引用,引用队列就相当于一个通知,多个引用队列和一个对象关联,对象被回收时,多个队列都会受到通知

3.4 watch()
@Synchronized fun watch(
  watchedReference: Any,
  referenceName: String
) {
  if (!isEnabled()) {
    return
  }
  //移除队列中将要被 GC 的引用
  removeWeaklyReachableReferences()
  val key = UUID.randomUUID()
      .toString()
  val watchUptimeMillis = clock.uptimeMillis()
  val reference = // 构建当前引用的弱引用对象,并关联引用队列 queue
    KeyedWeakReference(watchedReference, key, referenceName, watchUptimeMillis, queue)
  if (referenceName != "") {
    CanaryLog.d(
        "Watching instance of %s named %s with key %s", reference.className,
        referenceName, key
    )
  } else {
    CanaryLog.d(
        "Watching instance of %s with key %s", reference.className, key
    )
  }
​
  watchedReferences[key] = reference
  checkRetainedExecutor.execute {
    //如果引用未被移除,则可能存在内存泄漏
    moveToRetained(key)
  }
}
复制代码

removeWeaklyReachableReferences()

  private fun removeWeaklyReachableReferences() {
    // WeakReferences are enqueued as soon as the object to which they point to becomes weakly
    // reachable. This is before finalization or garbage collection has actually happened.
    // 弱引用一旦变得弱可达,就会立即入队。这将在 finalization 或者 GC 之前发生。
    var ref: KeyedWeakReference?
    do {
      ref = queue.poll() as KeyedWeakReference? // 队列 queue 中的对象都是会被 GC 的
      if (ref != null) {
        val removedRef = watchedReferences.remove(ref.key)
        if (removedRef == null) {
          retainedReferences.remove(ref.key)
        }
        // 移除 watchedReferences 队列中的会被 GC 的 ref 对象,剩下的就是可能泄露的对象
      }
    } while (ref != null)
  }
​
复制代码

moveToRetained()

  @Synchronized private fun moveToRetained(key: String) {
    removeWeaklyReachableReferences() // 再次调用,防止遗漏
    val retainedRef = watchedReferences.remove(key)
    if (retainedRef != null) {
      retainedReferences[key] = retainedRef
      onReferenceRetained()
    }
  }
​
复制代码

最后会回调到InternalLeakCanary的onReferenceRetained()方法

override fun onReferenceRetained() {
  if (this::heapDumpTrigger.isInitialized) {
    heapDumpTrigger.onReferenceRetained()
  }
}
​
//1.HeapDumpTrigger 的 onReferenceRetained()
fun onReferenceRetained() {
  scheduleRetainedInstanceCheck("found new instance retained")
}
​
//2.scheduleRetainedInstanceCheck
private fun scheduleRetainedInstanceCheck(reason: String) {
  backgroundHandler.post {
    checkRetainedInstances(reason)
  }
}
  
//3.checkRetainedInstances
private fun checkRetainedInstances(reason: String) {
  CanaryLog.d("Checking retained instances because %s", reason)
    val config = configProvider()
    // A tick will be rescheduled when this is turned back on.
    if (!config.dumpHeap) {
      return
    }
​
  var retainedKeys = refWatcher.retainedKeys
        //当前泄露实例个数小于 5 个,不进行 heap dump
    if (checkRetainedCount(retainedKeys, config.retainedVisibleThreshold)) return
​
      if (!config.dumpHeapWhenDebugging && DebuggerControl.isDebuggerAttached) {
        showRetainedCountWithDebuggerAttached(retainedKeys.size)
          scheduleRetainedInstanceCheck("debugger was attached", WAIT_FOR_DEBUG_MILLIS)
          CanaryLog.d(
          "Not checking for leaks while the debugger is attached, will retry in %d ms",
          WAIT_FOR_DEBUG_MILLIS
        )
          return
      }
        // 可能存在被观察的引用将要变得弱可达,但是还未入队引用队列。
    // 这时候应该主动调用一次 GC,可能可以避免一次 heap dump
  gcTrigger.runGc()
​
    retainedKeys = refWatcher.retainedKeys
​
    if (checkRetainedCount(retainedKeys, config.retainedVisibleThreshold)) return
​
      HeapDumpMemoryStore.setRetainedKeysForHeapDump(retainedKeys)
​
      CanaryLog.d("Found %d retained references, dumping the heap", retainedKeys.size)
      HeapDumpMemoryStore.heapDumpUptimeMillis = SystemClock.uptimeMillis()
      dismissNotification()
      val heapDumpFile = heapDumper.dumpHeap()
      if (heapDumpFile == null) {
        CanaryLog.d("Failed to dump heap, will retry in %d ms", WAIT_AFTER_DUMP_FAILED_MILLIS)
          scheduleRetainedInstanceCheck("failed to dump heap", WAIT_AFTER_DUMP_FAILED_MILLIS)
          showRetainedCountWithHeapDumpFailed(retainedKeys.size)
          return
      }
​
  refWatcher.removeRetainedKeys(retainedKeys)
​
    HeapAnalyzerService.runAnalysis(application, heapDumpFile)
}
复制代码

一些细节可以看看代码注释,checkRetainedCount满足个数的话,就要发起head dump,具体的逻辑在AndroidHeapDumper.dumpHeap()中:

  override fun dumpHeap(): File? {
    val heapDumpFile = leakDirectoryProvider.newHeapDumpFile() ?: return null
        ···
    return try {
     //Dump出文件
      Debug.dumpHprofData(heapDumpFile.absolutePath)
      heapDumpFile
    } catch (e: Exception) {
      CanaryLog.d(e, "Could not dump heap")
      // Abort heap dump
      null
    } finally {
      cancelToast(toast)
      notificationManager.cancel(R.id.leak_canary_notification_dumping_heap)
    }
  }
复制代码

最后启动一个前台服务 HeapAnalyzerService 来分析 heap dump 文件。老版本中是使用 Square 自己的 haha 库来解析的,这个库已经废弃了,Square 完全重写了解析库,主要逻辑都在 moudle leakcanary-analyzer 中。这部分我还没有阅读,就不在这里分析了。对于新的解析器,官网是这样介绍的:

Uses 90% less memory and 6 times faster than the prior heap parser.

减少了 90% 的内存占用,而且比原来快了 6 倍。后面有时间单独来分析一下这个解析库。

后面的过程就不再赘述了,通过解析库找到最短 GC Roots 引用路径,然后展示给用户。

4.手动写内存泄漏检测

下面是参考Zero的Demo写的内存泄漏检测的一个例子,思路和LeakCanary一样

fun main() {
​
    class MyKeyedWeakReference(
            referent: Any,
            val key: String,
            val name: String,
            referenceQueue: ReferenceQueue<Any>
    ) : WeakReference<Any>(
            referent, referenceQueue
    ) {
        val className: String = referent.javaClass.name
        override fun toString(): String {
            return "{key=$key,className=$className}"
        }
    }
    //需要观察的对象
    val watchedReferences = mutableMapOf<String,MyKeyedWeakReference>()
    //如果最后retainedReferences还存在引用,说明泄漏了
    val retainedReferences = mutableMapOf<String,MyKeyedWeakReference>()
    //当与之关联的弱引用中的实例被回收,则会加入到queue
    val gcQueue = ReferenceQueue<Any>()
​
    fun sleep(mills: Long){
        try {
            Thread.sleep(mills)
        }catch (e: Exception){
            e.printStackTrace()
        }
    }
​
    fun gc(){
        println("执行gc...")
        Runtime.getRuntime().gc()
        sleep(100)
        System.runFinalization()
    }
​
    fun removeWeaklyReachableReferences(){
        println("removeWeaklyReachableReferences")
        var ref: MyKeyedWeakReference?
        do {
            ref = gcQueue.poll() as MyKeyedWeakReference? //队列queue中的对象都是会被GC的
            println("ref=$ref,如果ref为null,说明对象还有强引用")
            if (ref != null){ //说明被释放了
                println("ref=$ref, 对象被释放了,key=${ref.key}")
                val removedRef = watchedReferences.remove(ref.key)
                println("removedRef=$removedRef, 如果removedRef为null,说明已经不在watchedReferences了,key=${ref.key}")
                if (removedRef == null){
                    //不在watchedReferences则说明在retainedReferences
                    retainedReferences.remove(ref.key)
                }
            }
        }while (ref != null)
    }
​
    @Synchronized
    fun moveToRetained(key: String){
        println("5.moveToRetained,key=$key")
        removeWeaklyReachableReferences()
        val retainedRef = watchedReferences.remove(key)
        println("retainedRef =$retainedRef 如果还有值说明没有被释放")
        if (retainedRef != null){ //添加到retainedReferences
            retainedReferences[key] = retainedRef
        }
​
    }
​
    fun watch(
            obj: Any,
            referenceName: String = ""){
        println("2.watch...")
        removeWeaklyReachableReferences()
        val key = UUID.randomUUID().toString()
        println("3.key=$key")
        val reference = MyKeyedWeakReference(obj,key,referenceName,gcQueue)
        println("4.reference=$reference")
        //加入观察列表
        watchedReferences[key] = reference
        //过段时间查看是否释放
        thread(start = true){
            sleep(5000)
            moveToRetained(key)
        }
​
    }
​
    var obj : Any? = Object()
    println("1.创建一个对象obj=$obj")
    watch(obj!!,"")
    sleep(2000)
    obj = null
    if (obj == null){
        println("obj=$obj 释放了")
    }
    gc()
    sleep(5000)
    println("watchedReferences=$watchedReferences")
    println("retainedReferences=$retainedReferences")
    println("执行完毕")
复制代码

5. ContentProvider的优化

5.1 Content的初始化顺序

通过ContentProvider来进行初始化确实能给使用者带来便利,但是会影响启动速度,如果有多个ContentProvider,如何控制这些ContentProvider初始化的顺序呢,可以参考下面这篇文章sivanliu.github.io/2017/12/16/…,如果一些第三方值只提供ContentProvider的初始化方式,我们又不想影响我们APP的启动时间,该如何处理呢?

5.2

如果一些第三方库只提供ContentProvider的初始化方式,我们又不想影响我们APP的启动时间,该如何处理呢?我们可以使用AOP方式进行插桩,通过Gradle+Transform+ASM进行修改ContentProvider的onCreate方法,提前返回,然后手动去调用初始化代码,如果这些初始化代码是私有的或者只限制包内使用的,也可以通过ASM去修改访问权限,然后在我们想初始化的地方再进行初始化,这可能涉及到一个先后的问题,需要先修改完然后再在某个地方初始化,这里只是提供一个思路。如果一个库初始化耗时很长,又在ContentProvider中进行初始化,ContentProvider中初始化的代码又臭又长,又没有提供其他初始化方法,这样的垃圾库你要它干嘛!

6.总结

  1. 利用ContentProvider自动初始化,无需用户手动初始化
  2. GC回收,引用队列
  3. 1.5.4之后支持fragment,支持androidx
  4. 当泄露引用到达 5 个时才会发起 heap dump
  5. 全新的 heap parser,减少 90% 内存占用,提升 6 倍速度
  6. ContentProvider的优劣,以及优化方案

公众号:【Android老皮】 希望写出的东西能对你起到帮助 🤣🤣

分类:
Android