一步步拆解 LeakCanary

本篇文章已受权微信公众号 guolin_blog (郭霖)独家发布java

java 源码系列 - 带你读懂 Reference 和 ReferenceQueueandroid

blog.csdn.net/gdutxiaoxu/…git

一步步拆解 LeakCanarygithub

blog.csdn.net/gdutxiaoxu/…面试

前言

内存泄露,一直是咱们性能优化方面的重点。今天,就让咱们一块儿来拆解 LeakCanary,一步步理解它的原理性能优化

原理概览

讲解 LeakCannary 原理以前,咱们先来讲一下它的主要原理,给你们吃颗定心丸,其实挺简单的,大概能够分为如下几步:bash

  • 监听 Activity 的生命周期
  • 在 onDestroy 的时候,建立相应的 Refrence 和 RefrenceQueue,并启动后台进程去检测
  • 一段时间以后,从 RefrenceQueue 读取,若读取不到相应 activity 的 Refrence,有可能发生泄露了,这个时候,再促发 gc,一段时间以后,再去读取,若在从 RefrenceQueue 仍是读取不到相应 activity 的 refrence,能够判定是发生内存泄露了
  • 发生内存泄露以后,dump,分析 hprof 文件,找到泄露路径(使用 haha 库分析),发送到通知栏

原理分析

LeakCanary#Install

public static RefWatcher install(Application application) {
  return refWatcher(application).listenerServiceClass(DisplayLeakService.class)
      .excludedRefs(AndroidExcludedRefs.createAppDefaults().build())
      .buildAndInstall();
}
复制代码

listenerServiceClass 方法

public AndroidRefWatcherBuilder listenerServiceClass(
    Class<? extends AbstractAnalysisResultService> listenerServiceClass) {
  return heapDumpListener(new ServiceHeapDumpListener(context, listenerServiceClass));
}

public final class ServiceHeapDumpListener implements HeapDump.Listener {

  private final Context context;
  private final Class<? extends AbstractAnalysisResultService> listenerServiceClass;

  public ServiceHeapDumpListener(Context context,
      Class<? extends AbstractAnalysisResultService> listenerServiceClass) {
     // 启动后台服务监听
    setEnabled(context, listenerServiceClass, true);
    // 启动 HeapAnalyzerService ,用来分析 dump 文件
    setEnabled(context, HeapAnalyzerService.class, true);
    this.listenerServiceClass = checkNotNull(listenerServiceClass, "listenerServiceClass");
    this.context = checkNotNull(context, "context").getApplicationContext();
  }

  ----  
}
复制代码

listenerServiceClass() 方法绑定了一个后台服务 DisplayLeakService,这个服务主要用来分析内存泄漏结果并发送通知。你能够继承并重写这个类来进行一些自定义操做,好比上传分析结果等。微信

RefWatcherBuilder.excludedRefs

public final T excludedRefs(ExcludedRefs excludedRefs) {
  this.excludedRefs = excludedRefs;
  return self();
}
AndroidExcludedRefs.java
/**
 * This returns the references in the leak path that can be ignored for app developers. This
 * doesn't mean there is no memory leak, to the contrary. However, some leaks are caused by bugs * in AOSP or manufacturer forks of AOSP. In such cases, there is very little we can do as app * developers except by resorting to serious hacks, so we remove the noise caused by those leaks. */ public static ExcludedRefs.Builder createAppDefaults() { return createBuilder(EnumSet.allOf(AndroidExcludedRefs.class)); } public static ExcludedRefs.Builder createBuilder(EnumSet<AndroidExcludedRefs> refs) { ExcludedRefs.Builder excluded = ExcludedRefs.builder(); for (AndroidExcludedRefs ref : refs) { if (ref.applies) { ref.add(excluded); ((ExcludedRefs.BuilderWithParams) excluded).named(ref.name()); } } return excluded; } 复制代码

excludedRefs() 方法定义了一些对于开发者能够忽略的路径,意思就是即便这里发生了内存泄漏,LeakCanary 也不会弹出通知。这大可能是系统 Bug 致使的,无需用户进行处理。并发

AndroidRefWatcherBuilder.buildAndInstall

buildAndInstall 所作的工做,调用 build 构建 refWatcher,判断 refWatcher 是否 DISABLED,若不是 DISABLED 状态,调用 install 方法,并将 refWatcher 返回回去app

/**
 * Creates a {@link RefWatcher} instance and starts watching activity references (on ICS+).
 */
public RefWatcher buildAndInstall() {
  // 构建 refWatcher 对象
  RefWatcher refWatcher = build();
  // 判断是否 DISABLED,若不是 DISABLED 状态,调用 
  if (refWatcher != DISABLED) {
    LeakCanary.enableDisplayLeakActivity(context);
    ActivityRefWatcher.install((Application) context, refWatcher);
  }
  return refWatcher;
}
复制代码

了解 build 方法 以前,咱们先来看一下 RefWatcherBuilder 是什么东东?

RefWatcherBuilder

public class RefWatcherBuilder<T extends RefWatcherBuilder<T>> {

  private ExcludedRefs excludedRefs;
  private HeapDump.Listener heapDumpListener;
  private DebuggerControl debuggerControl;
  private HeapDumper heapDumper;
  private WatchExecutor watchExecutor;
  private GcTrigger gcTrigger;



  /** Creates a {@link RefWatcher}. */
  public final RefWatcher build() {
    if (isDisabled()) {
      return RefWatcher.DISABLED;
    }

    ExcludedRefs excludedRefs = this.excludedRefs;
    if (excludedRefs == null) {
      excludedRefs = defaultExcludedRefs();
    }

    HeapDump.Listener heapDumpListener = this.heapDumpListener;
    if (heapDumpListener == null) {
      heapDumpListener = defaultHeapDumpListener();
    }

    DebuggerControl debuggerControl = this.debuggerControl;
    if (debuggerControl == null) {
      debuggerControl = defaultDebuggerControl();
    }

    HeapDumper heapDumper = this.heapDumper;
    if (heapDumper == null) {
      heapDumper = defaultHeapDumper();
    }

    WatchExecutor watchExecutor = this.watchExecutor;
    if (watchExecutor == null) {
      watchExecutor = defaultWatchExecutor();
    }

    GcTrigger gcTrigger = this.gcTrigger;
    if (gcTrigger == null) {
      gcTrigger = defaultGcTrigger();
    }

    return new RefWatcher(watchExecutor, debuggerControl, gcTrigger, heapDumper, heapDumpListener,
        excludedRefs);
  }
  
  ----


复制代码

build 方法看到这里你是否是有一种很眼熟的感受,没错,它运用了建造者模式,与咱们 Android 中的 AlertDialog.build 同出一辙。 建造者模式(Builder)及其应用

RefWatcherBuilder 主要有几个重要的成员变量

  • watchExecutor : 线程控制器,在 onDestroy() 以后而且主线程空闲时执行内存泄漏检测
  • debuggerControl : 判断是否处于调试模式,调试模式中不会进行内存泄漏检测
  • gcTrigger : 用于 GC,watchExecutor 首次检测到可能的内存泄漏,会主动进行 GC,GC 以后会再检测一次,仍然泄漏的断定为内存泄漏,进行后续操做
  • heapDumper : dump 内存泄漏处的 heap 信息,写入 hprof 文件
  • heapDumpListener : 解析完 hprof 文件,进行回调,并通知 DisplayLeakService 弹出提醒
  • excludedRefs : 排除能够忽略的泄漏路径

接下来,咱们一块儿来看一下 ActivityRefWatcher.install 方法

ActivityRefWatcher.install((Application) context, refWatcher);
复制代码
public final class ActivityRefWatcher {

  /** @deprecated Use {@link #install(Application, RefWatcher)}. */
  @Deprecated
  public static void installOnIcsPlus(Application application, RefWatcher refWatcher) {
    install(application, refWatcher);
  }

  public static void install(Application application, RefWatcher refWatcher) {
    new ActivityRefWatcher(application, refWatcher).watchActivities();
  }

  private final Application.ActivityLifecycleCallbacks lifecycleCallbacks =
      new Application.ActivityLifecycleCallbacks() {
        @Override public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
        }

        @Override public void onActivityStarted(Activity activity) {
        }

        @Override public void onActivityResumed(Activity activity) {
        }

        @Override public void onActivityPaused(Activity activity) {
        }

        @Override public void onActivityStopped(Activity activity) {
        }

        @Override public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
        }

        @Override public void onActivityDestroyed(Activity activity) {
          ActivityRefWatcher.this.onActivityDestroyed(activity);
        }
      };

  private final Application application;
  private final RefWatcher refWatcher;

  /**
   * Constructs an {@link ActivityRefWatcher} that will make sure the activities are not leaking
   * after they have been destroyed.
   */
  public ActivityRefWatcher(Application application, RefWatcher refWatcher) {
    this.application = checkNotNull(application, "application");
    this.refWatcher = checkNotNull(refWatcher, "refWatcher");
  }

  void onActivityDestroyed(Activity activity) {
    refWatcher.watch(activity);
  }

  public void watchActivities() {
    // Make sure you don't get installed twice. stopWatchingActivities(); application.registerActivityLifecycleCallbacks(lifecycleCallbacks); } public void stopWatchingActivities() { application.unregisterActivityLifecycleCallbacks(lifecycleCallbacks); } } 复制代码

install 来讲,主要作如下事情

  • 建立 ActivityRefWatcher,并调用 watchActivities 监听 activity 的生命周期
  • 在 activity 被销毁的时候,会回调 lifecycleCallbacks 的 onActivityDestroyed 方法,这时候会调用 onActivityDestroyed 去分析,而 onActivityDestroyed 方法又会回调 refWatcher.watch(activity)

咱们回到 refWatcher.watch 方法

public void watch(Object watchedReference) {
  watch(watchedReference, "");
}

/**
 * Watches the provided references and checks if it can be GCed. This method is non blocking,
 * the check is done on the {@link WatchExecutor} this {@link RefWatcher} has been constructed
 * with.
 *
 * @param referenceName An logical identifier for the watched object.
 */
public void watch(Object watchedReference, String referenceName) {
  if (this == DISABLED) {
    return;
  }
  checkNotNull(watchedReference, "watchedReference");
  checkNotNull(referenceName, "referenceName");
  final long watchStartNanoTime = System.nanoTime();
  // 保证 key 的惟一性
  String key = UUID.randomUUID().toString();
  // 添加到 set 集合中
  retainedKeys.add(key);
  // 穿件 KeyedWeakReference 对象
  final KeyedWeakReference reference =
      new KeyedWeakReference(watchedReference, key, referenceName, queue);

  ensureGoneAsync(watchStartNanoTime, reference);
}


复制代码
  • retainedKeys : 一个 Set 集合,每一个检测的对象都对应着一个惟一的 key,存储在 retainedKeys 中
  • KeyedWeakReference : 自定义的弱引用,持有检测对象和对用的 key 值

咱们先来看一下 KeyedWeakReference ,能够看到 KeyedWeakReference 继承于 WeakReference,并定义了 key,name 字段

final class KeyedWeakReference extends WeakReference<Object> {
  public final String key;
  public final String name;

  KeyedWeakReference(Object referent, String key, String name,
      ReferenceQueue<Object> referenceQueue) {
    super(checkNotNull(referent, "referent"), checkNotNull(referenceQueue, "referenceQueue"));
    this.key = checkNotNull(key, "key");
    this.name = checkNotNull(name, "name");
  }
}
复制代码
  • key 对应的 key 值名称
  • referenceQueue 引用队列,当结合 Refrence 使用的时候,垃圾回收器回收的时候,会把相应的对象加入到 refrenceQueue 中。

弱引用和引用队列 ReferenceQueue 联合使用时,若是弱引用持有的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。即 KeyedWeakReference 持有的 Activity 对象若是被垃圾回收,该对象就会加入到引用队列 queue 中。具体的能够参考个人这一篇博客 java 源码系列 - 带你读懂 Reference 和 ReferenceQueue

ensureGoneAsync 方法

private void ensureGoneAsync(final long watchStartNanoTime, final KeyedWeakReference reference) {
  watchExecutor.execute(new Retryable() {
    @Override public Retryable.Result run() {
      return ensureGone(reference, watchStartNanoTime);
    }
  });
}
复制代码

ensureGoneAsync 这个方法,在 watchExecutor 的回调里面执行了 ensureGone 方法,watchExecutor 是 AndroidWatchExecutor 的实例。

接下来,咱们一块儿来看一下 watchExecutor,主要关注 execute 方法

watchExecutor

public final class AndroidWatchExecutor implements WatchExecutor {

  static final String LEAK_CANARY_THREAD_NAME = "LeakCanary-Heap-Dump";
  private final Handler mainHandler;
  private final Handler backgroundHandler;
  private final long initialDelayMillis;
  private final long maxBackoffFactor;

  public AndroidWatchExecutor(long initialDelayMillis) {
    mainHandler = new Handler(Looper.getMainLooper());
    HandlerThread handlerThread = new HandlerThread(LEAK_CANARY_THREAD_NAME);
    handlerThread.start();
    backgroundHandler = new Handler(handlerThread.getLooper());
    this.initialDelayMillis = initialDelayMillis;
    maxBackoffFactor = Long.MAX_VALUE / initialDelayMillis;
  }

  @Override public void execute(Retryable retryable) {
    // 当前线程是主线程
    if (Looper.getMainLooper().getThread() == Thread.currentThread()) {
      waitForIdle(retryable, 0);
    } else { // 当前线程不是主线程
      postWaitForIdle(retryable, 0);
    }
  }

   --------
}

复制代码

execute 方法,首先判断是不是主线程,若是是主线程,调用 waitForIdle 方法,等待空闲的时候执行,若是不是主线程,调用 postWaitForIdle 方法。咱们一块儿来看一下 postWaitForIdle 和 waitForIdle 方法。

// 调用 mainHandler 的 post 方法,,确保在主线程中执行
  void postWaitForIdle(final Retryable retryable, final int failedAttempts) {
    mainHandler.post(new Runnable() {
      @Override public void run() {
        waitForIdle(retryable, failedAttempts);
      }
    });
  }

 // 当当前线程 looper 空闲的时候执行
  void waitForIdle(final Retryable retryable, final int failedAttempts) {
    // This needs to be called from the main thread.
    // 当 looper 空闲的时候,会回调 queueIdle 方法
    Looper.myQueue().addIdleHandler(new MessageQueue.IdleHandler() {
      @Override public boolean queueIdle() {
        postToBackgroundWithDelay(retryable, failedAttempts);
        return false;
      }
    });
  }
复制代码

能够看到 postWaitForIdle 方法实际上是 调用 mainHandler 的 post 方法,,确保在主线程中执行,以后再 runnable 的 run 方法在调用 waitForIdle 方法。而 waitForIdle 方法是在等当前 looper 空闲以后,执行 postToBackgroundWithDelay 方法

void postToBackgroundWithDelay(final Retryable retryable, final int failedAttempts) {
  // 取 Math.pow(2, failedAttempts), maxBackoffFactor 的最小值,maxBackoffFactor = Long.MAX_VALUE / 5,
  // 第一次执行的时候 failedAttempts 是 0 ,因此 exponentialBackoffFactor 是1
    long exponentialBackoffFactor = (long) Math.min(Math.pow(2, failedAttempts), maxBackoffFactor);
    // initialDelayMillis 的默认值是 5
    long delayMillis = initialDelayMillis * exponentialBackoffFactor;
    // 因此第一次延迟执行的时候是 5s,若
    backgroundHandler.postDelayed(new Runnable() {
      @Override public void run() {
        Retryable.Result result = retryable.run();
        // 过 result == RETRY,再次调用 postWaitForIdle,下一次的 delayMillis= 上一次的  delayMillis *2;
        // 正常状况下,不会返回 RETRY,当 heapDumpFile == RETRY_LATER (即 dump heap 失败的时候),会返回 RETRY
        if (result == RETRY) {
          postWaitForIdle(retryable, failedAttempts + 1);
        }
      }
    }, delayMillis);
  }
复制代码

postToBackgroundWithDelay 方法有点相似递归,正常状况下,若 retryable.run() 返回的结果不等于 RETRY,只会执行一次。若 retryable.run() 返回 RETRY,则会执行屡次,退出的条件是 retryable.run() 返回结果不等于 RETRY;

delay 的时间 取 Math.pow(2, failedAttempts), maxBackoffFactor 两个数的最小值,maxBackoffFactor = Long.MAX_VALUE / 5,而,第一次执行的时候 failedAttempts 是 0 ,因此 exponentialBackoffFactor 是 1,即 delayMillis = initialDelayMillis * exponentialBackoffFactor= 5*1=5;

所以,综合上面的例子,第一次执行的时间是 activity destroy 以后 5s。

OK,咱们回到 ensureGone 方法,这才是咱们的重点

@SuppressWarnings("ReferenceEquality") // Explicitly checking for named null.
Retryable.Result ensureGone(final KeyedWeakReference reference, final long watchStartNanoTime) {
  long gcStartNanoTime = System.nanoTime();
  long watchDurationMs = NANOSECONDS.toMillis(gcStartNanoTime - watchStartNanoTime);

   // 移除已经被回收的引用
  removeWeaklyReachableReferences();

  if (debuggerControl.isDebuggerAttached()) {
    // The debugger can create false leaks.
    return RETRY;
  }
  // 判断 reference,即 activity 是否内回收了,若被回收了,直接返回
  if (gone(reference)) {
    return DONE;
  }
  // 调用 gc 方法进行垃圾回收
  gcTrigger.runGc();
   // 移除已经被回收的引用
  removeWeaklyReachableReferences();
  // activity 尚未被回收,证实发生内存泄露
  if (!gone(reference)) {
    long startDumpHeap = System.nanoTime();
    long gcDurationMs = NANOSECONDS.toMillis(startDumpHeap - gcStartNanoTime);
    // dump heap,并生成相应的 hprof 文件
    File heapDumpFile = heapDumper.dumpHeap();
    
    if (heapDumpFile == RETRY_LATER) {// dump the heap 失败的时候
      // Could not dump the heap.
      return RETRY;
    }
    long heapDumpDurationMs = NANOSECONDS.toMillis(System.nanoTime() - startDumpHeap);
    // 分析 hprof 文件
    heapdumpListener.analyze(
        new HeapDump(heapDumpFile, reference.key, reference.name, excludedRefs, watchDurationMs,
            gcDurationMs, heapDumpDurationMs));
  }
  return DONE;
}


复制代码

removeWeaklyReachableReferences 方法

private void 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.
  KeyedWeakReference ref;
  // 遍历 queue ,并从 retainedKeys set 集合中移除
  while ((ref = (KeyedWeakReference) queue.poll()) != null) {
    retainedKeys.remove(ref.key);
  }
}


复制代码

gone(reference) 方法,判断 retainedKeys set 集合,是否还含有 reference,若没有,证实已经被回收了;若含有,可能已经发生内存泄露。由于咱们知道 refrence 被回收的时候,会被加进 queue 里面,值调用 gone 方法判断的时候,咱们已经遍历 queue 移除掉 retainedKeys 里面的 refrence,若含有,证实 refrence 没有被回收,之因此说可能发生内存泄露,是由于 gc 回收器可能尚未回收。

private boolean gone(KeyedWeakReference reference) {
  return !retainedKeys.contains(reference.key);
}


复制代码

gcTrigger.runGc() 的主要做用是促发 gc,进行回收。

GcTrigger DEFAULT = new GcTrigger() {
    @Override public void 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 perfom a gc.
      Runtime.getRuntime().gc();
      enqueueReferences();
      System.runFinalization();
    }

    private void 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 (InterruptedException e) { throw new AssertionError(); } } }; 复制代码

ok,咱们在回到 ensureGoneAsync 方法,整理一下它的流程

  • Activity onDestroy 5s 以后,检测 activity 的弱引用 refrence 有没有被回收,若被回收,证实没有发生内存泄露,若没有被回收,继续下面流程
  • 调用 gcTrigger.runGc() 促发垃圾回收机器进行回收
  • 再次检测 activity 的弱引用 refrence 有没有被回收,若被回收,证实没有发生内存泄露,若没有被回收,则认为发生内存泄露
  • dump heap,生成 hprof。
  • 分析 hprof 文件,找到泄露路径,发送到通知栏

关于如何 dump 和 如何解析hprof

关于如何 dump

这里主要是调用 AndroidHeapDumper 的 dumpHeap 方法,而里面比较重要的是调用 Debug.dumpHprofData 生成 hprof 文件。

AndroidHeapDumper#dumpHeap

@SuppressWarnings("ReferenceEquality") // Explicitly checking for named null.
@Override public File dumpHeap() {
  File heapDumpFile = leakDirectoryProvider.newHeapDumpFile();

  if (heapDumpFile == RETRY_LATER) {
    return RETRY_LATER;
  }

  FutureResult<Toast> waitingForToast = new FutureResult<>();
  showToast(waitingForToast);

  if (!waitingForToast.wait(5, SECONDS)) {
    CanaryLog.d("Did not dump heap, too much time waiting for Toast.");
    return RETRY_LATER;
  }

  Toast toast = waitingForToast.get();
  try {
    Debug.dumpHprofData(heapDumpFile.getAbsolutePath());
    cancelToast(toast);
    return heapDumpFile;
  } catch (Exception e) {
    CanaryLog.d(e, "Could not dump heap");
    // Abort heap dump
    return RETRY_LATER;
  }
}
复制代码

如何解析hprof

当发生了泄漏就会生成 HeapDump 对象而后就会进入下面这个方法去启动 HeapAnalyzerServiceService 来进行分析

@Override public void analyze(HeapDump heapDump) {
    checkNotNull(heapDump, "heapDump");
    HeapAnalyzerService.runAnalysis(context, heapDump, listenerServiceClass);
  }
复制代码

关于如解析 hprof,请自行了解 haha 库的用法即原理

public AnalysisResult checkForLeak(File heapDumpFile, String referenceKey) {
    long analysisStartNanoTime = System.nanoTime();

    if (!heapDumpFile.exists()) {
      Exception exception = new IllegalArgumentException("File does not exist: " + heapDumpFile);
      return failure(exception, since(analysisStartNanoTime));
    }

    try {
      HprofBuffer buffer = new MemoryMappedFileBuffer(heapDumpFile);
      HprofParser parser = new HprofParser(buffer);
      Snapshot snapshot = parser.parse();
      deduplicateGcRoots(snapshot);

      Instance leakingRef = findLeakingReference(referenceKey, snapshot);

      // False alarm, weak reference was cleared in between key check and heap dump.
      if (leakingRef == null) {
        return noLeak(since(analysisStartNanoTime));
      }

      return findLeakTrace(analysisStartNanoTime, snapshot, leakingRef);
    } catch (Throwable e) {
      return failure(e, since(analysisStartNanoTime));
    }
  }
复制代码

通过解析以后机会把数据传递到 DisplayLeakService ,Service 会根据传入进来的数据发送通知栏通知,当你点击对应的通知进入DisplayLeakActivity界面就能显示泄漏日志了。


总结:

LeakCanary 的原理总结以下

  • 监听 Activity 的生命周期
  • 在 onDestroy 的时候,建立相应的 Refrence 和 RefrenceQueue,并启动后台进程去检测
  • 一段时间以后,从 RefrenceQueue 读取,若读取不到相应 activity 的 Refrence,有可能发生泄露了,这个时候,再促发 gc,一段时间以后,再去读取,若在从 RefrenceQueue 仍是读取不到相应 activity 的 refrence,能够判定是发生内存泄露了
  • 发生内存泄露以后,dump,分析 hprof 文件,找到泄露路径(使用 haha 库分析)

其中,比较重要的是如何肯定是否发生内存泄露,而如何肯定发生内存泄露最主要的原理是经过 Refrence 和 RefrenceQueue。悄悄地提醒你一下,面试必备。

最后,用一张图片来表示 leakCannary 的执行流程,该图片来自 深刻理解 Android 之 LeakCanary 源码解析

image

java 源码系列 - 带你读懂 Reference 和 ReferenceQueue

blog.csdn.net/gdutxiaoxu/…

一步步拆解 LeakCanary

blog.csdn.net/gdutxiaoxu/…

最后的最后

卖一下广告,欢迎你们关注个人微信公众号,扫一扫下方二维码或搜索微信号 stormjun,便可关注。 目前专一于 Android 开发,主要分享 Android开发相关知识和一些相关的优秀文章,包括我的总结,职场经验等。

image
相关文章
相关标签/搜索