【Android】LeakCanary原理解析


学而不思则罔,思而不学则殆

源码

https://github.com/square/leakcanary/tags
下载的是1.6.1的版本,本来想Git clone的。但是国内的网络不行,clone超时,也想通过其他方式,就直接下载了一个ZIP的包,也方便。

概述

LeakCanary是Square公司为Android开发者提供的一个自动检测内存泄漏的工具
LeakCanary本质上是一个基于MAT进行Android应用程序内存泄漏自动化检测的的开源工具,我们可以通过集成LeakCanary提供的jar包到自己的工程中,一旦检测到内存泄漏,LeakCanary就好dump Memory信息,并通过另一个进程分析内存泄漏的信息并展示出来,随时发现和定位内存泄漏问题,而不用每次在开发流程中都抽出专人来进行内存泄漏问题检测,极大地方便了Android应用程序的开发。
Leak使用可以大致分为以下四个方面:注册,观察,分析和展示

在这里插入图片描述

注册

注册调用非常简单,一句话就OK:

public class ExampleApplication extends Application {
  @Override public void onCreate() {
    super.onCreate();
    LeakCanary.install(this);
  }
}

但是中间干的事也不少,来看看注册过程中的时序图:

注册时序图

LeakCanary注册逻辑
注册阶段主要是生成引用观察者{RefWatcher},然后注册到Application的生命周期方法中。当{onActivityCreated(Activity, Bundle)}回调被调用的时候,通过观察{Activity}引用是否全部回收。

注册类图

在这里插入图片描述

1.install

  //LeakCanary.java
  public static RefWatcher install(Application application) {
    return refWatcher(application).listenerServiceClass(DisplayLeakService.class)
        .excludedRefs(AndroidExcludedRefs.createAppDefaults().build())
        .buildAndInstall();
  }
  
  public static AndroidRefWatcherBuilder refWatcher(Context context) {
    return new AndroidRefWatcherBuilder(context);
  }

调用{refWatcher}生成一个{AndroidRefWatcherBuilder},{AndroidRefWatcherBuilder}这是一个采用构建者模式的构建类,主要是用来提供生成{RefWatcher}的参数,直接跳过中间设置参数的地方,直接看{buildAndInstall}方法;

2.buildAndInstall

类图如下:
在这里插入图片描述

  //AndroidRefWatcherBuilder.java
  public RefWatcher buildAndInstall() {
    if (LeakCanaryInternals.installedRefWatcher != null) {
      throw new UnsupportedOperationException("buildAndInstall() should only be called once.");
    }
    RefWatcher refWatcher = build(); //1.构建RefWatcher 
    if (refWatcher != DISABLED) {
      if (watchActivities) {
        ActivityRefWatcher.install(context, refWatcher);//2.注册Activity监听
      }
      if (watchFragments) {
        FragmentRefWatcher.Helper.install(context, refWatcher);//3.注册Fragment监听
      }
    }
    LeakCanaryInternals.installedRefWatcher = refWatcher;
    return refWatcher;
  }

这个方法干了两件事,构建RefWatcher 和注册应用生命周期回调;其中注册监听回调分为ActivityFragment

1.build

通过调用父类{RefWatcherBuilder}的builde方法生成RefWathcer.采用的构建者模式;
在这里插入图片描述

  //RefWatcherBuilder.java
  public final RefWatcher build() {
    if (isDisabled()) {
      return RefWatcher.DISABLED;
    }

    if (heapDumpBuilder.excludedRefs == null) {
      heapDumpBuilder.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();
    }

    if (heapDumpBuilder.reachabilityInspectorClasses == null) {
      heapDumpBuilder.reachabilityInspectorClasses(defaultReachabilityInspectorClasses());
    }

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

2.注册生命周期回调

  //ActivityRefWatcher.java
  public static void install(Context context, RefWatcher refWatcher) {
    Application application = (Application) context.getApplicationContext();//1.采用ApplicationContext
    ActivityRefWatcher activityRefWatcher = new ActivityRefWatcher(application, refWatcher);//2.创建ActivityRefWatcher
    application.registerActivityLifecycleCallbacks(activityRefWatcher.lifecycleCallbacks);//3.注册回调
  }

  private final Application.ActivityLifecycleCallbacks lifecycleCallbacks =
      new ActivityLifecycleCallbacksAdapter() {
        @Override public void onActivityDestroyed(Activity activity) {
          refWatcher.watch(activity);
        }
      };
  ...
  private final Application application;
  private final RefWatcher refWatcher;

  private ActivityRefWatcher(Application application, RefWatcher refWatcher) {
    this.application = application;
    this.refWatcher = refWatcher;
  }

这里经过注册Activity的生命周期回调,整个注册过程就算结束了。当Activity执行onDestroyed方法时,就是调用onActivityDestroyed方法,接下来就进入到观察阶段了。
其中Fragment注册跟Activity差不多,这里就不在赘述。
接下来就进入观察阶段…

观察

先来一个观察阶段的时序图:

观察时序图

在这里插入图片描述

  //ActivityRefWatcher.java
  private final Application.ActivityLifecycleCallbacks lifecycleCallbacks =
      new ActivityLifecycleCallbacksAdapter() {
        @Override public void onActivityDestroyed(Activity activity) {
          refWatcher.watch(activity);
        }
      };
      
  private final Application application;
  private final RefWatcher refWatcher;

  private ActivityRefWatcher(Application application, RefWatcher refWatcher) {
    this.application = application;
    this.refWatcher = refWatcher;
  }

接收回调{onActivityDestroyed}的时候,调用RefWatcherwatch方法。把Activity加入到观察队列中。
那就来看看RefWatcher类是什么?图先整上。

RefWatcher类图

在这里插入图片描述
根据前面我们可以知道该:

  //RefWatcherBuilder.java
  public final RefWatcher build() {
    ...
    //创建RefWatcher
    return new RefWatcher(watchExecutor, debuggerControl, gcTrigger, heapDumper, heapDumpListener,
        heapDumpBuilder);
  }

1.watch

  /**
   * Identical to {@link #watch(Object, String)} with an empty string reference name.
   *
   * @see #watch(Object, String)
   */
  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) { //release版本中会生成一个DISABLED对象,直接返回
      return;
    }
    checkNotNull(watchedReference, "watchedReference");
    checkNotNull(referenceName, "referenceName");
    final long watchStartNanoTime = System.nanoTime();
    String key = UUID.randomUUID().toString(); //1.随机生成key
    retainedKeys.add(key); //2.加入到Set中,用来判断该对象是否被回收
    final KeyedWeakReference reference =
        new KeyedWeakReference(watchedReference, key, referenceName, queue);//创建KeyedWeakReference 

    ensureGoneAsync(watchStartNanoTime, reference);
  }

2.ensureGoneAsync

进入异步队列

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

WatchExecutor是一个接口,其默认实现是空实现。WatchExecutorRetryable 的关系可以类比为线程池Runnable的关系

public interface WatchExecutor {
  WatchExecutor NONE = new WatchExecutor() {
    @Override public void execute(Retryable retryable) {
    }
  };

  void execute(Retryable retryable);
}

当然在Android的实现是{AndroidWatchExecutor},类图如下。
在这里插入图片描述
{AndroidWatchExecutor}中有两个Handler,一个是主线程MainHandler,一个是子线程ThreadHandler。

  //AndroidWatchExecutor.java
  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);
    }
  }

子类实现分为主线程调用子线程调用

子线程调用

  private void postWaitForIdle(final Retryable retryable, final int failedAttempts) {
    mainHandler.post(new Runnable() {
      @Override public void run() {
        waitForIdle(retryable, failedAttempts);
      }
    });
  }

可以看到这里子线程调用还是扔到了主线程的{waitForIdle}方法。

主线程调用

  private void waitForIdle(final Retryable retryable, final int failedAttempts) {
    // This needs to be called from the main thread.
    Looper.myQueue().addIdleHandler(new MessageQueue.IdleHandler() {
      @Override public boolean queueIdle() {
        postToBackgroundWithDelay(retryable, failedAttempts);
        return false;
      }
    });
  }

很奇怪,怎么是{addIdleHandler}而不是直接处理呢?因为addIdleHandler调用的时机是消息空闲的时候被调用,不能GC操作不能阻塞主线程UI的信息。先了解Handler 空闲消息的小伙伴可以看Andriod-消息机制Handler
由于这个方法{waitForIdle}是在主线程被调用,所以这里的MQ也是主线的MQ,queueIdle返回的false,该消息会在结束后移除空闲消息队列。

    //MessageQueue.java
    Message next() {
        ...
        for (;;) {
            ...
            //
            for (int i = 0; i < pendingIdleHandlerCount; i++) {
                final IdleHandler idler = mPendingIdleHandlers[i];
                mPendingIdleHandlers[i] = null; // release the reference to the handler

                boolean keep = false;
                try {
                    keep = idler.queueIdle(); //空闲消息执行
                } catch (Throwable t) {
                    Log.wtf(TAG, "IdleHandler threw exception", t);
                }

                if (!keep) { //移除空闲消息
                    synchronized (this) {
                        mIdleHandlers.remove(idler);
                    }
                }
            }
            ...
        }
    }

刚刚分析我们发现,主线程和子线程都会调用{postToBackgroundWithDelay}方法

postToBackgroundWithDelay

在主线程空闲的时候,执行空闲消息,将消息放到子线程{new HandlerThread(LEAK_CANARY_THREAD_NAME)}

  private void postToBackgroundWithDelay(final Retryable retryable, final int failedAttempts) {
    long exponentialBackoffFactor = (long) Math.min(Math.pow(2, failedAttempts), maxBackoffFactor);
    long delayMillis = initialDelayMillis * exponentialBackoffFactor;
    backgroundHandler.postDelayed(new Runnable() {
      @Override public void run() {
        Retryable.Result result = retryable.run();
        if (result == RETRY) {
          postWaitForIdle(retryable, failedAttempts + 1);
        }
      }
    }, delayMillis);
  }

这里才是真正调用的地方,调用{retryable.run()}。delayMillis的计算有一些控制重试次数和初始化延时的逻辑。
这是时候回到{2.ensureGoneAsync},会调用{ensureGone}

3.ensureGone

  //RefWatcher.java
  @SuppressWarnings("ReferenceEquality") // Explicitly checking for named null.
  Retryable.Result ensureGone(final KeyedWeakReference reference, final long watchStartNanoTime) {
    long gcStartNanoTime = System.nanoTime();  //1.GC开始时间
    long watchDurationMs = NANOSECONDS.toMillis(gcStartNanoTime - watchStartNanoTime); //2.GC开始时间-观察开始时间

    removeWeaklyReachableReferences(); //3.移除被回收对象的引用key

    if (debuggerControl.isDebuggerAttached()) {
      // The debugger can create false leaks.
      return RETRY;
    }
    if (gone(reference)) { //4.判断当前reference引用的对象是否已经被回收掉
      return DONE;
    }
    gcTrigger.runGc(); //5.触发GC
    removeWeaklyReachableReferences();//6.再一次移除被回收对象的引用key
    if (!gone(reference)) { //7.再一次判断当前reference引用的对象是否已经被回收掉
      long startDumpHeap = System.nanoTime();//8.开始dump时间
      long gcDurationMs = NANOSECONDS.toMillis(startDumpHeap - gcStartNanoTime);//9.计算GC花费的时间

      File heapDumpFile = heapDumper.dumpHeap(); //10.dump文件
      if (heapDumpFile == RETRY_LATER) {
        // Could not dump the heap.
        return RETRY; //重试
      }
      long heapDumpDurationMs = NANOSECONDS.toMillis(System.nanoTime() - startDumpHeap); //11.统计dump花费的时间
      //12.构建HeapDump,很重要,后续分析全靠它
      HeapDump heapDump = heapDumpBuilder.heapDumpFile(heapDumpFile).referenceKey(reference.key)
          .referenceName(reference.name)
          .watchDurationMs(watchDurationMs)
          .gcDurationMs(gcDurationMs)
          .heapDumpDurationMs(heapDumpDurationMs)
          .build();
     
      heapdumpListener.analyze(heapDump);//13.回调,主要是回调给服务,去分析泄漏原因
    }
    return DONE;
  }

这里算是观察过程中的最重要的逻辑,可以仔细阅读一下。中间有用到弱引用回收队列,想了解的可以查看【Java】Java的四种引用

1.GC开始时间
2.GC开始时间-观察开始时间
3.移除被回收对象的引用key
4.判断当前reference引用的对象是否已经被回收掉
5.触发GC
6.再一次移除被回收对象的引用key
7.再一次判断当前reference引用的对象是否已经被回收掉
8.开始dump时间
9.计算GC花费的时间
10.dump文件
11.统计dump花费的时间
12.构建HeapDump,很重要,后续分析全靠它
13.回调,主要是回调给服务,去分析泄漏原因

这中间的相关类几乎都采用的接口的模式来实现,便于复用和理解。

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;
    while ((ref = (KeyedWeakReference) queue.poll()) != null) {
      retainedKeys.remove(ref.key);
    }
  }

如果KeyedWeakReference 引用的对象被回收了,KeyedWeakReference 对象就会被添加到ReferenceQueue队列中,然后在移除retainedKeys中的key.

gone

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

如果retainedKeys中不包含{reference.key},则说明这个引用对象已经被回收了。否则没有被回收。

runGc

在这里插入图片描述
默认实现:

public interface GcTrigger {
  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(); //触发GC
      enqueueReferences(); //等待100ms
      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();
      }
    }
  };

  void runGc();
}

dumpHeap

如果GC过后,retainedKeys中任然持有该引用的KEY,则说明这个对象没有被回收,发生了泄漏,dumo泄漏的情况。
在这里插入图片描述
实现在{AndroidHeapDumper}.

创建HeapDump

在这里插入图片描述
dump文件最终的文件是什么样子?

2020-08-23 15:32:37.666 28620-28644/com.example.leakcanary I/System.out: ensureGone heapDumpFile:/storage/emulated/0/Download/leakcanary-com.example.leakcanary/d6fa75e0-a70b-4c9d-9075-f901876318ae_pending.hprof
            HeapDump heapDump = heapDumpBuilder
                    .heapDumpFile(heapDumpFile)
                    .referenceKey(reference.key)
                    .referenceName(reference.name)
                    .watchDurationMs(watchDurationMs)
                    .gcDurationMs(gcDurationMs)
                    .heapDumpDurationMs(heapDumpDurationMs)
                    .build();

这里有三个时间:

时间说明
watchDurationMs观察到GC的时间间隔
gcDurationMsGC花费的时间
heapDumpDurationMsdump花费的时间

回调analyze

启动服务,分析和后面展示。
在这里插入图片描述

public final class ServiceHeapDumpListener implements HeapDump.Listener {

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

  public ServiceHeapDumpListener(final Context context,
      final Class<? extends AbstractAnalysisResultService> listenerServiceClass) {
    this.listenerServiceClass = checkNotNull(listenerServiceClass, "listenerServiceClass");
    this.context = checkNotNull(context, "context").getApplicationContext();
  }

  @Override public void analyze(HeapDump heapDump) {
    checkNotNull(heapDump, "heapDump");
    HeapAnalyzerService.runAnalysis(context, heapDump, listenerServiceClass); //启动服务
  }
}

我们来看看HeapDump 对象是什么样子?

HeapDump{heapDumpFile=/storage/emulated/0/Download/leakcanary-com.example.leakcanary/0d1469fe-88b0-4722-bc73-0c346057e78f_pending.hprof
    , referenceKey='b05c7f1d-c1b3-4d16-919a-d5c8e673c0a8'
    , referenceName=''
    , excludedRefs=| Field: android.os.Message.obj
    | Field: android.os.Message.next
    | Field: android.os.Message.target
    | Field: android.view.Choreographer$FrameDisplayEventReceiver.mMessageQueue (always)
    | Thread:FinalizerWatchdogDaemon (always)
    | Thread:main (always)
    | Thread:LeakCanary-Heap-Dump (always)
    | Class:java.lang.ref.WeakReference (always)
    | Class:java.lang.ref.SoftReference (always)
    | Class:java.lang.ref.PhantomReference (always)
    | Class:java.lang.ref.Finalizer (always)
    | Class:java.lang.ref.FinalizerReference (always)
    
    , watchDurationMs=5021
    , gcDurationMs=143
    , heapDumpDurationMs=2606
    , computeRetainedHeapSize=false
    , reachabilityInspectorClasses=[class com.squareup.leakcanary.AndroidReachabilityInspectors$ViewInspector, class com.squareup.leakcanary.AndroidReachabilityInspectors$ActivityInspector, class com.squareup.leakcanary.AndroidReachabilityInspectors$DialogInspector, class com.squareup.leakcanary.AndroidReachabilityInspectors$ApplicationInspector, class com.squareup.leakcanary.AndroidReachabilityInspectors$FragmentInspector, class com.squareup.leakcanary.AndroidReachabilityInspectors$SupportFragmentInspector, class com.squareup.leakcanary.AndroidReachabilityInspectors$MessageQueueInspector, class com.squareup.leakcanary.AndroidReachabilityInspectors$MortarPresenterInspector]}

其中excludedRefs表示需要忽略的引用,也可以通过{createBuilder(EnumSet refs)}来自定义需要忽略的引用。其中{AndroidExcludedRefs}中定义各种Android需要忽略的引用,感兴趣的可以仔细看看。
很奇怪,看不懂,没事后面我们来分析,这个对象是怎么得出内存泄漏的呢?并找出泄漏路径的呢?

分析

先看一下其时序图:

分析过程时序图

在这里插入图片描述
应该能看出来,主要是在{HeapAnalyzer}

HeapAnalyzerService

public final class HeapAnalyzerService extends ForegroundService
        implements AnalyzerProgressListener {
    ...
    @Override
    protected void onHandleIntentInForeground(@Nullable Intent intent) {
        if (intent == null) {
            CanaryLog.d("HeapAnalyzerService received a null intent, ignoring.");
            return;
        }
        String listenerClassName = intent.getStringExtra(LISTENER_CLASS_EXTRA);
        HeapDump heapDump = (HeapDump) intent.getSerializableExtra(HEAPDUMP_EXTRA);
        //1。构建HeapAnalyzer 
        HeapAnalyzer heapAnalyzer = new HeapAnalyzer(heapDump.excludedRefs, this, heapDump.reachabilityInspectorClasses);    
        //2.开始分析
        AnalysisResult result = heapAnalyzer.checkForLeak(heapDump.heapDumpFile, heapDump.referenceKey, heapDump.computeRetainedHeapSize);
        //3.发送分析结果,展示
        AbstractAnalysisResultService.sendResultToListener(this, listenerClassName, heapDump, result);
    }
    ...
}

{HeapAnalyzerService}主要试分析heapDumpFile文件是否存在泄漏。最重要的是第二步。

HeapAnalyzer

    //HeapAnalyzer.java
    /**
     * Searches the heap dump for a {@link KeyedWeakReference} instance with the corresponding key,
     * and then computes the shortest strong reference path from that instance to the GC roots.
     */
    public AnalysisResult checkForLeak(File heapDumpFile, String referenceKey,
                                       boolean computeRetainedSize) {
        long analysisStartNanoTime = System.nanoTime();

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

        try {
            listener.onProgressUpdate(READING_HEAP_DUMP_FILE);
            HprofBuffer buffer = new MemoryMappedFileBuffer(heapDumpFile);
            HprofParser parser = new HprofParser(buffer);
            listener.onProgressUpdate(PARSING_HEAP_DUMP);
            Snapshot snapshot = parser.parse();
            listener.onProgressUpdate(DEDUPLICATING_GC_ROOTS);
            deduplicateGcRoots(snapshot);
            listener.onProgressUpdate(FINDING_LEAKING_REF);
            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, computeRetainedSize);
        } catch (Throwable e) {
            return failure(e, since(analysisStartNanoTime));
        }
    }

其中{deduplicateGcRoots}和{findLeakingReference}方法才是重点。关于这两个方法的分析详见下回分解!!!

AbstractAnalysisResultService

AbstractAnalysisResultService主要作用是拿到前面的分析结果进行展示,篇幅有限,不在赘述。

展示

  //LeakCanaryInternals.java
  public static final String SAMSUNG = "samsung";
  public static final String MOTOROLA = "motorola";
  public static final String LENOVO = "LENOVO";
  public static final String LG = "LGE";
  public static final String NVIDIA = "NVIDIA";
  public static final String MEIZU = "Meizu";
  public static final String HUAWEI = "HUAWEI";
  public static final String VIVO = "vivo";
  
  public static void showNotification(Context context, CharSequence contentTitle,
      CharSequence contentText, PendingIntent pendingIntent, int notificationId) {
    Notification.Builder builder = new Notification.Builder(context)
        .setContentText(contentText)
        .setContentTitle(contentTitle)
        .setAutoCancel(true)
        .setContentIntent(pendingIntent);

    Notification notification = buildNotification(context, builder);
    NotificationManager notificationManager =
        (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
    notificationManager.notify(notificationId, notification); //发送通知
  }
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值