IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 移动开发 -> android之LeakCanary原理分析 -> 正文阅读

[移动开发]android之LeakCanary原理分析

1.官网

LeakCanary是Android的内存泄漏检测库,官方地址LeakCanary

2.什么是内存泄漏

在基于Java的运行时中,内存泄漏是一种编程错误,导致应用程序保留对不再需要的对象的引用。因此,分配给该对象的内存无法回收,最终导致OutOfMemoryError(OOM)崩溃。

例如,在调用Android Activity实例的onDestroy()方法后,不再需要该实例,并且在静态字段中存储对该实例的引用可以防止该实例被垃圾收集。

3.内存泄漏的常见原因

大多数内存泄漏是由与对象生命周期相关的错误引起的。以下是一些常见的Android错误:

  • 单例对象需要传Context,将某个Activity当作Context传递,Activity销毁后单例对象还是持有该Activity的引用
  • 广播、RxJava订阅等,注册后忘记取消注册

4.LeakCanary工作原理

分4个步骤自动检测并报告内存泄漏

检测保留的对象

LeakCanary 挂钩到 Android 生命周期,以自动检测活动和片段何时被销毁并应进行垃圾回收。这些被销毁的对象被传递给一个ObjectWatcher,它持有对它们的弱引用。LeakCanary 自动检测以下对象的泄漏:

  • 销毁的Activity实例
  • 销毁的Fragment实例
  • 销毁的片段View实例
  • 清除ViewModel实例

如果ObjectWatcher等待 5 秒并运行垃圾收集后没有清除所持有的弱引用,则被监视的对象被认为是保留的,并且可能存在泄漏。

LeakCanary 在转储堆之前等待保留对象的计数达到阈值,并显示带有最新计数的通知栏。

默认阈值5保留的对象时,应用程序是可见的,和1保留对象时,应用程序是不可见的。如果您看到保留的对象通知,然后将应用程序置于后台(例如通过按下主页按钮),则阈值将从 5 变为 1,LeakCanary 会在 5 秒内转储堆。点击通知会强制 LeakCanary 立即转储堆。

转储堆

当保留对象的数量达到阈值时,LeakCanary 将 Java 堆转储到存储在 Android 文件系统上的.hprof文件(堆转储)中

分析堆

LeakCanary使用Shark解析.hprof文件,并在该堆转储中定位保留的对象。

对于每个保留对象,LeakCanary 找到阻止该保留对象被垃圾收集的引用路径:它的泄漏跟踪

LeakCanary为每个泄漏跟踪创建一个签名,并将具有相同签名的泄漏(即由相同错误引起的泄漏)组合在一起。

分类泄漏

LeakCanary将其在应用程序中发现的漏洞分为两类:应用程序漏洞和库漏洞。库泄漏是由您无法控制的第三方代码中的已知错误引起的泄漏。此漏洞正在影响您的应用程序,但不幸的是,修复此漏洞可能不在您的控制范围内,因此LeakCanary将其分离出来。

5.修复内存泄漏

1.找到泄漏痕迹

泄漏迹是对于较短的名称从垃圾收集根 至 保留对象 最好强参考路径,即,被保持物体在存储器引用的路径,因此防止它被垃圾收集。

例如,让我们在静态字段中存储一个辅助单例:

class Helper {
}

class Utils {
  public static Helper helper = new Helper();
}

让我们告诉 LeakCanary 单例实例应该被垃圾回收:

AppWatcher.objectWatcher.watch(Utils.helper)

该单身人士的泄漏跟踪如下所示:

┬───
│ GC Root: Local variable in native code
│
├─ dalvik.system.PathClassLoader instance
│    ↓ PathClassLoader.runtimeInternalObjects
├─ java.lang.Object[] array
│    ↓ Object[].[43]
├─ com.example.Utils class
│    ↓ static Utils.helper
╰→ java.example.Helper

让我们分解它!在顶部,一个PathClassLoader实例由垃圾回收 (GC) root持有,更具体地说,是本机代码中的局部变量。GC 根是始终可达的特殊对象,即它们不能被垃圾回收。GC 根有 4 种主要类型:

  • 局部变量,属于线程的堆栈。
  • 活动 Java 线程的实例。
  • 系统类,从不卸载。
  • 本机引用,由本机代码控制。
┬───
│ GC Root: Local variable in native code
│
├─ dalvik.system.PathClassLoader instance

以 开头的行├─代表一个 Java 对象(类、对象数组或实例),以 开头的行│ ↓代表对下一行 Java 对象的引用。

PathClassLoader有一个runtimeInternalObjects字段是对以下数组的引用Object

├─ dalvik.system.PathClassLoader instance
│    ↓ PathClassLoader.runtimeInternalObjects
├─ java.lang.Object[] array

该数组中位置 43 处的元素Object是对Utils类的引用。

├─ java.lang.Object[] array
│    ↓ Object[].[43]
├─ com.example.Utils class

以 开头的行╰→表示泄漏对象,即传递给AppWatcher.objectWatcher.watch()的对象。

所述Utils类具有静态helper字段,它是对泄漏对象,这是助手单个实例的引用:

├─ com.example.Utils class
│    ↓ static Utils.helper
╰→ java.example.Helper instance

2. 缩小可疑的参考范围

泄漏跟踪是引用路径。最初,该路径中的所有引用都被怀疑导致泄漏,但 LeakCanary 可以自动缩小可疑引用的范围。要了解这意味着什么,让我们手动完成该过程。

下面是一个糟糕的 Android 代码示例:

class ExampleApplication : Application() {
  val leakedViews = mutableListOf<View>()
}

class MainActivity : Activity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.main_activity)

    val textView = findViewById<View>(R.id.helper_text)

    val app = application as ExampleApplication
    // This creates a leak, What a Terrible Failure!
    app.leakedViews.add(textView)
  }
}

LeakCanary 生成的泄漏跟踪如下所示:

┬───
│ GC Root: System class
│
├─ android.provider.FontsContract class
│    ↓ static FontsContract.sContext
├─ com.example.leakcanary.ExampleApplication instance
│    ↓ ExampleApplication.leakedViews
├─ java.util.ArrayList instance
│    ↓ ArrayList.elementData
├─ java.lang.Object[] array
│    ↓ Object[].[0]
├─ android.widget.TextView instance
│    ↓ TextView.mContext
╰→ com.example.leakcanary.MainActivity instance

以下是读取泄漏跟踪的方法:

FontsContract类是一个系统类(参见GC Root: System class),并具有一个sContext静态字段,它引用了ExampleApplication其中有一个实例leakedViews的字段,它引用的ArrayList实例,其引用的阵列(该阵列支持数组列表实现),它具有参照的一个元件TextView,其具有一个mContext场它引用了 的销毁实例MainActivity

LeakCanary 使用~~~下划线突出显示所有怀疑导致此泄漏的引用。最初,所有引用都是可疑的:

┬───
│ GC Root: System class
│
├─ android.provider.FontsContract class
│    ↓ static FontsContract.sContext
│                           ~~~~~~~~
├─ com.example.leakcanary.ExampleApplication instance
│    Leaking: NO (Application is a singleton)
│    ↓ ExampleApplication.leakedViews
│                         ~~~~~~~~~~~
├─ java.util.ArrayList instance
│    ↓ ArrayList.elementData
│                ~~~~~~~~~~~
├─ java.lang.Object[] array
│    ↓ Object[].[0]
│               ~~~
├─ android.widget.TextView instance
│    ↓ TextView.mContext
│               ~~~~~~~~
╰→ com.example.leakcanary.MainActivity instance

然后,LeakCanary对泄漏跟踪中的对象的状态生命周期进行推断。在 Android 应用程序中,Application实例是一个单例,永远不会被垃圾收集,因此它永远不会泄漏 (?Leaking: NO (Application is a singleton))。由此,LeakCanary 得出结论,泄漏不是由FontsContract.sContext(删除相应的~~~)引起的。这是更新的泄漏跟踪:

┬───
│ GC Root: System class
│
├─ android.provider.FontsContract class
│    ↓ static FontsContract.sContext
├─ com.example.leakcanary.ExampleApplication instance
│    Leaking: NO (Application is a singleton)
│    ↓ ExampleApplication.leakedViews
│                         ~~~~~~~~~~~
├─ java.util.ArrayList instance
│    ↓ ArrayList.elementData
│                ~~~~~~~~~~~
├─ java.lang.Object[] array
│    ↓ Object[].[0]
│               ~~~
├─ android.widget.TextView instance
│    ↓ TextView.mContext
│               ~~~~~~~~
╰→ com.example.leakcanary.MainActivity instance

TexView实例MainActivity通过它的mContext字段引用被销毁的实例。视图不应在其上下文的生命周期中存活,因此 LeakCanary 知道此TexView实例正在泄漏 (?Leaking: YES (View.mContext references a destroyed activity)),因此泄漏不是由TextView.mContext(删除相应的~~~)引起的。这是更新的泄漏跟踪:

┬───
│ GC Root: System class
│
├─ android.provider.FontsContract class
│    ↓ static FontsContract.sContext
├─ com.example.leakcanary.ExampleApplication instance
│    Leaking: NO (Application is a singleton)
│    ↓ ExampleApplication.leakedViews
│                         ~~~~~~~~~~~
├─ java.util.ArrayList instance
│    ↓ ArrayList.elementData
│                ~~~~~~~~~~~
├─ java.lang.Object[] array
│    ↓ Object[].[0]
│               ~~~
├─ android.widget.TextView instance
│    Leaking: YES (View.mContext references a destroyed activity)
│    ↓ TextView.mContext
╰→ com.example.leakcanary.MainActivity instance

总而言之,LeakCanary 检查泄漏跟踪中对象的状态以确定这些对象是否泄漏 (?Leaking: YESvs?Leaking: NO),并利用该信息缩小可疑引用的范围。您可以提供自定义ObjectInspector实现来改进 LeakCanary 在您的代码库中的工作方式(请参阅识别泄漏对象和标记对象)。

3.找到导致泄漏的原因

需要开发者分析代码

4.修复泄漏

需要开发者分析代码

6.Shark

Shark 是支持 LeakCanary 2 的堆分析器。它是一个 Kotlin 独立堆分析库,以低内存占用高速运行。

Shark 是分层释放的:

  1. Shark Hprof?: 在 hprof 文件中读写记录。
  2. Shark Graph:浏览堆对象图。
  3. Shark:生成堆分析报告。
  4. Shark Android:Android 启发式生成定制的堆分析报告。
  5. Shark CLI:分析安装在连接到桌面的 Android 设备上的可调试应用程序堆。输出类似于 LeakCanary 的输出,不同之处在于您不必将 LeakCanary 依赖项添加到您的应用程序中。
  6. LeakCanary:建立在顶部。它会自动监视被破坏的活动和片段,触发堆转储,运行 Shark Android,然后显示结果。

7.关键代码分析

com.squareup.leakcanary.RefWatcher

//Gc触发
public interface GcTrigger {
    GcTrigger DEFAULT = new GcTrigger() {
        public void runGc() {
            //告诉垃圾收集器打算进行垃圾收集,而垃圾收集器进不进行收集是不确定的
            Runtime.getRuntime().gc();
            //排队引用
            this.enqueueReferences();
            //调用此方法,意味着Java虚拟机将最大努力将精力花在运行 已发现被丢弃但其finalize方法 
            //尚未运行的对象的finalize方法上。
            System.runFinalization();
        }

        private void enqueueReferences() {
            try {
                Thread.sleep(100L);
            } catch (InterruptedException var2) {
                throw new AssertionError();
            }
        }
    };

    void runGc();
}

public final class RefWatcher {
    public static final RefWatcher DISABLED = (new RefWatcherBuilder()).build();
    private final WatchExecutor watchExecutor;
    private final DebuggerControl debuggerControl;
    private final GcTrigger gcTrigger;
    private final HeapDumper heapDumper;
    private final Listener heapdumpListener;
    private final Builder heapDumpBuilder;
    private final Set<String> retainedKeys;
    private final ReferenceQueue<Object> queue;

    RefWatcher(WatchExecutor watchExecutor, DebuggerControl debuggerControl, GcTrigger gcTrigger, HeapDumper heapDumper, Listener heapdumpListener, Builder heapDumpBuilder) {
        this.watchExecutor = (WatchExecutor)Preconditions.checkNotNull(watchExecutor, "watchExecutor");
        this.debuggerControl = (DebuggerControl)Preconditions.checkNotNull(debuggerControl, "debuggerControl");
        this.gcTrigger = (GcTrigger)Preconditions.checkNotNull(gcTrigger, "gcTrigger");
        this.heapDumper = (HeapDumper)Preconditions.checkNotNull(heapDumper, "heapDumper");
        this.heapdumpListener = (Listener)Preconditions.checkNotNull(heapdumpListener, "heapdumpListener");
        this.heapDumpBuilder = heapDumpBuilder;
        this.retainedKeys = new CopyOnWriteArraySet();
        this.queue = new ReferenceQueue();
    }

    public void watch(Object watchedReference) {
        this.watch(watchedReference, "");
    }
 
    //watch方法
    //监听提供的引用,检查该引用是否可以被回收。
    //随机生成UUIDkey用于唯一标识准备检测的对象,创建KeyedWeakReference弱引用对象
    public void watch(Object watchedReference, String referenceName) {
        if (this != DISABLED) {
            Preconditions.checkNotNull(watchedReference, "watchedReference");
            Preconditions.checkNotNull(referenceName, "referenceName");
            long watchStartNanoTime = System.nanoTime();
            String key = UUID.randomUUID().toString();
            this.retainedKeys.add(key);
            KeyedWeakReference reference = new KeyedWeakReference(watchedReference, key, referenceName, this.queue);
            this.ensureGoneAsync(watchStartNanoTime, reference);
        }
    }

    public void clearWatchedReferences() {
        this.retainedKeys.clear();
    }

    boolean isEmpty() {
        this.removeWeaklyReachableReferences();
        return this.retainedKeys.isEmpty();
    }

    Builder getHeapDumpBuilder() {
        return this.heapDumpBuilder;
    }

    Set<String> getRetainedKeys() {
        return new HashSet(this.retainedKeys);
    }

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


    //ensureGone
    //1、ensureGone方法是在异步任务执行的,不会对主线程造成阻塞;
    //2、二次调用removeWeaklyReachableReferences()、gcTrigger.runGc()判断弱引用对象是否已经销毁;
    //3、如果二次操作后还是存在对象则判断为内存泄漏了开始.hprof文件的dump;
    //4、调用analyze(heapDump)进行泄漏分析;
    Result ensureGone(KeyedWeakReference reference, long watchStartNanoTime) {
        long gcStartNanoTime = System.nanoTime();
        long watchDurationMs = TimeUnit.NANOSECONDS.toMillis(gcStartNanoTime - watchStartNanoTime);
        this.removeWeaklyReachableReferences();
        if (this.debuggerControl.isDebuggerAttached()) {
            return Result.RETRY;
        } else if (this.gone(reference)) {
            return Result.DONE;
        } else {
            this.gcTrigger.runGc();
            this.removeWeaklyReachableReferences();
            if (!this.gone(reference)) {
                long startDumpHeap = System.nanoTime();
                long gcDurationMs = TimeUnit.NANOSECONDS.toMillis(startDumpHeap - gcStartNanoTime);
                File heapDumpFile = this.heapDumper.dumpHeap();
                if (heapDumpFile == HeapDumper.RETRY_LATER) {
                    return Result.RETRY;
                }

                long heapDumpDurationMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startDumpHeap);
                HeapDump heapDump = this.heapDumpBuilder.heapDumpFile(heapDumpFile).referenceKey(reference.key).referenceName(reference.name).watchDurationMs(watchDurationMs).gcDurationMs(gcDurationMs).heapDumpDurationMs(heapDumpDurationMs).build();
                this.heapdumpListener.analyze(heapDump);
            }

            return Result.DONE;
        }
    }

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


    private void removeWeaklyReachableReferences() {
        KeyedWeakReference ref;
        while((ref = (KeyedWeakReference)this.queue.poll()) != null) {
            this.retainedKeys.remove(ref.key);
        }

    }
}

watch方法
? 1、监听提供的引用,检查该引用是否可以被回收。
? 2、随机生成UUIDkey用于唯一标识准备检测的对象,创建KeyedWeakReference弱引用对象

ensureGone方法
? 1、ensureGone方法是在异步任务执行的,不会对主线程造成阻塞;
? 2、二次调用removeWeaklyReachableReferences()、gcTrigger.runGc()判断弱引用对象是否已经销毁;
? 3、如果二次操作后还是存在对象则判断为内存泄漏了开始.hprof文件的dump;
? 4、调用analyze(heapDump)进行泄漏分析;

KeyedWeakReference对象

KeyedWeakReference泛型中传递的Object对象会被传入ReferenceQueue中,当一个obj被gc掉之后,其相应的包装类,即ref对象会被放?ReferenceQueue中,我们可以从queue中获取到相应的对象信息,同时进行一些处理等。

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(Preconditions.checkNotNull(referent, "referent"), (ReferenceQueue)Preconditions.checkNotNull(referenceQueue, "referenceQueue"));
        this.key = (String)Preconditions.checkNotNull(key, "key");
        this.name = (String)Preconditions.checkNotNull(name, "name");
    }
}

HeapAnalyzer泄漏分析

com.squareup.leakcanary.HeapAnalyzer
public final class HeapAnalyzer {
  
  /**
   * 在dump的堆信息中通过key查找出内存泄漏的弱引用对象,并且计算出该对象到GC根的最短强引用路径。
   */
  public @NonNull AnalysisResult checkForLeak(@NonNull File heapDumpFile,
      @NonNull 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) {
        String className = leakingRef.getClassObj().getClassName();
        return noLeak(className, since(analysisStartNanoTime));
      }
      
      //找到泄漏路径
      return findLeakTrace(analysisStartNanoTime, snapshot, leakingRef, computeRetainedSize);
    } catch (Throwable e) {
      return failure(e, since(analysisStartNanoTime));
    }
  }

  /**
   * 过滤掉重复数据
   */
  void deduplicateGcRoots(Snapshot snapshot) {
    // THashMap has a smaller memory footprint than HashMap.
    final THashMap<String, RootObj> uniqueRootMap = new THashMap<>();

    final Collection<RootObj> gcRoots = snapshot.getGCRoots();
    for (RootObj root : gcRoots) {
      String key = generateRootKey(root);
      if (!uniqueRootMap.containsKey(key)) {
        uniqueRootMap.put(key, root);
      }
    }

    // Repopulate snapshot with unique GC roots.
    gcRoots.clear();
    uniqueRootMap.forEach(new TObjectProcedure<String>() {
      @Override public boolean execute(String key) {
        return gcRoots.add(uniqueRootMap.get(key));
      }
    });
  }

  /**
   * 找到KeyedWeakReference对象集合
   */
  private Instance findLeakingReference(String key, Snapshot snapshot) {
    ClassObj refClass = snapshot.findClass(KeyedWeakReference.class.getName());
    if (refClass == null) {
      throw new IllegalStateException(
          "Could not find the " + KeyedWeakReference.class.getName() + " class in the heap dump.");
    }
    List<String> keysFound = new ArrayList<>();
    for (Instance instance : refClass.getInstancesList()) {
      List<ClassInstance.FieldValue> values = classInstanceValues(instance);
      Object keyFieldValue = fieldValue(values, "key");
      if (keyFieldValue == null) {
        keysFound.add(null);
        continue;
      }
      String keyCandidate = asString(keyFieldValue);
      if (keyCandidate.equals(key)) {
        return fieldValue(values, "referent");
      }
      keysFound.add(keyCandidate);
    }
    throw new IllegalStateException(
        "Could not find weak reference with key " + key + " in " + keysFound);
  }

  /**
   * 找到泄漏路径
   */
  private AnalysisResult findLeakTrace(long analysisStartNanoTime, Snapshot snapshot,
      Instance leakingRef, boolean computeRetainedSize) {

    listener.onProgressUpdate(FINDING_SHORTEST_PATH);
    //最短路径
    ShortestPathFinder pathFinder = new ShortestPathFinder(excludedRefs);
    ShortestPathFinder.Result result = pathFinder.findPath(snapshot, leakingRef);

    String className = leakingRef.getClassObj().getClassName();

    // False alarm, no strong reference path to GC Roots.
    if (result.leakingNode == null) {
      return noLeak(className, since(analysisStartNanoTime));
    }

    listener.onProgressUpdate(BUILDING_LEAK_TRACE);
    LeakTrace leakTrace = buildLeakTrace(result.leakingNode);

    long retainedSize;
    if (computeRetainedSize) {

      listener.onProgressUpdate(COMPUTING_DOMINATORS);
      // Side effect: computes retained size.
      snapshot.computeDominators();

      Instance leakingInstance = result.leakingNode.instance;

      retainedSize = leakingInstance.getTotalRetainedSize();

      // TODO: check O sources and see what happened to android.graphics.Bitmap.mBuffer
      if (SDK_INT <= N_MR1) {
        listener.onProgressUpdate(COMPUTING_BITMAP_SIZE);
        retainedSize += computeIgnoredBitmapRetainedSize(snapshot, leakingInstance);
      }
    } else {
      retainedSize = AnalysisResult.RETAINED_HEAP_SKIPPED;
    }

    return leakDetected(result.excludingKnownLeaks, className, leakTrace, retainedSize,
        since(analysisStartNanoTime));
  }


}

在dump的堆信息中通过key查找出内存泄漏的弱引用对象,并且计算出该对象到GC根的最短强引用路径。

主要步骤:

1.生成文件的快照Snapshot对象

2.deduplicateGcRoots()过滤重复数据

3.findLeakingReference()查找出内存泄漏对象

4.findLeakTrace()查找内存泄漏对象到GC根的最短强引用路径

  移动开发 最新文章
Vue3装载axios和element-ui
android adb cmd
【xcode】Xcode常用快捷键与技巧
Android开发中的线程池使用
Java 和 Android 的 Base64
Android 测试文字编码格式
微信小程序支付
安卓权限记录
知乎之自动养号
【Android Jetpack】DataStore
上一篇文章      下一篇文章      查看所有文章
加:2021-12-04 13:33:24  更:2021-12-04 13:35:31 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/27 23:58:49-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码