zoukankan      html  css  js  c++  java
  • Android DiskLruCache 源码解析 硬盘缓存的绝佳方案

    转载请标明出处:
    http://blog.csdn.net/lmj623565791/article/details/47251585
    本文出自:【张鸿洋的博客】

    一、概述

    依旧是整理东西,所以近期的博客涉及的东西可能会比较老一点,会分析一些经典的框架,我觉得可能也是每个优秀的开发者必须掌握的东西;那么对于Disk Cache,DiskLruCache可以算佼佼者了,所以我们就来分析下其源码实现。

    对于该库的使用,推荐老郭的blog Android DiskLruCache完全解析,硬盘缓存的最佳方案

    如果你不是很了解用法,那么注意下面的几点描述,不然直接看源码分析可能雨里雾里的。

    • 首先,这个框架会涉及到一个文件,叫做journal,这个文件中会存储每次读取操作的记录;
    • 对于获取一个DiskLruCache,是这样的:

      DiskLruCache.open(directory, appVersion, 
                          valueCount, maxSize) ;
    • 关于存一般是这么使用的:

      String key = generateKey(url);  
      DiskLruCache.Editor editor = mDiskLruCache.edit(key); 
      OuputStream os = editor.newOutputStream(0); 

      因为每个实体都是个文件,所以你可以认为这个os指向一个文件的FileOutputStream,然后把你想存的东西写入就行了,写完以后记得调用:editor.commit()

    • 关于取一般是这样的:

       DiskLruCache.Snapshot snapShot = mDiskLruCache.get(key);  
      if (snapShot != null) {  
          InputStream is = snapShot.getInputStream(0);  
      }

      还是那句,因为每个实体都是文件,所以你返回的is是个FileInputStream,你可以利用is读取出里面的内容,然后do what you want .

    好了,关于Cache最主要就是存取了,了解这几点,就可以往下去看源码分析了。

    还记得第一点说的journal文件么,首先就是它了。


    二、journal文件

    journal文件你打开以后呢,是这个格式;

    libcore.io.DiskLruCache
    1
    1
    1
    
    DIRTY c3bac86f2e7a291a1a200b853835b664
    CLEAN c3bac86f2e7a291a1a200b853835b664 4698
    READ c3bac86f2e7a291a1a200b853835b664
    DIRTY c59f9eec4b616dc6682c7fa8bd1e061f
    CLEAN c59f9eec4b616dc6682c7fa8bd1e061f 4698
    READ c59f9eec4b616dc6682c7fa8bd1e061f
    DIRTY be8bdac81c12a08e15988555d85dfd2b
    CLEAN be8bdac81c12a08e15988555d85dfd2b 99
    READ be8bdac81c12a08e15988555d85dfd2b
    DIRTY 536788f4dbdffeecfbb8f350a941eea3
    REMOVE 536788f4dbdffeecfbb8f350a941eea3 
    

    首先看前五行:

    • 第一行固定字符串libcore.io.DiskLruCache
    • 第二行DiskLruCache的版本号,源码中为常量1
    • 第三行为你的app的版本号,当然这个是你自己传入指定的
    • 第四行指每个key对应几个文件,一般为1
    • 第五行,空行

    ok,以上5行可以称为该文件的文件头,DiskLruCache初始化的时候,如果该文件存在需要校验该文件头。

    接下来的行,可以认为是操作记录。

    • DIRTY 表示一个entry正在被写入(其实就是把文件的OutputStream交给你了)。那么写入分两种情况,如果成功会紧接着写入一行CLEAN的记录;如果失败,会增加一行REMOVE记录。
    • REMOVE除了上述的情况呢,当你自己手动调用remove(key)方法的时候也会写入一条REMOVE记录。
    • READ就是说明有一次读取的记录。
    • 每个CLEAN的后面还记录了文件的长度,注意可能会一个key对应多个文件,那么就会有多个数字(参照文件头第四行)。

    从这里看出,只有CLEAN且没有REMOVE的记录,才是真正可用的Cache Entry记录。

    分析完journal文件,首先看看DiskLruCache的创建的代码。


    三、DiskLruCache#open

    public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
          throws IOException {
    
        // If a bkp file exists, use it instead.
        File backupFile = new File(directory, JOURNAL_FILE_BACKUP);
        if (backupFile.exists()) {
          File journalFile = new File(directory, JOURNAL_FILE);
          // If journal file also exists just delete backup file.
          if (journalFile.exists()) {
            backupFile.delete();
          } else {
            renameTo(backupFile, journalFile, false);
          }
        }
    
        // Prefer to pick up where we left off.
        DiskLruCache cache = new DiskLruCache(directory, appVersion, valueCount, maxSize);
        if (cache.journalFile.exists()) {
          try {
            cache.readJournal();
            cache.processJournal();
            return cache;
          } catch (IOException journalIsCorrupt) {
            System.out
                .println("DiskLruCache "
                    + directory
                    + " is corrupt: "
                    + journalIsCorrupt.getMessage()
                    + ", removing");
            cache.delete();
          }
        }
    
        // Create a new empty cache.
        directory.mkdirs();
        cache = new DiskLruCache(directory, appVersion, valueCount, maxSize);
        cache.rebuildJournal();
        return cache;
      }
    

    首先检查存不存在journal.bkp(journal的备份文件)

    如果存在:然后检查journal文件是否存在,如果正主在,bkp文件就可以删除了。
    如果不存在,将bkp文件重命名为journal文件。

    接下里判断journal文件是否存在:

    • 如果不存在

      创建directory;重新构造disklrucache;调用rebuildJournal建立journal文件

      /**
      * Creates a new journal that omits redundant information. This replaces the
      * current journal if it exists.
      */
      private synchronized void rebuildJournal() throws IOException {
      if (journalWriter != null) {
        journalWriter.close();
      }
      
      Writer writer = new BufferedWriter(
          new OutputStreamWriter(new FileOutputStream(journalFileTmp), Util.US_ASCII));
      try {
        writer.write(MAGIC);
        writer.write("
      ");
        writer.write(VERSION_1);
        writer.write("
      ");
        writer.write(Integer.toString(appVersion));
        writer.write("
      ");
        writer.write(Integer.toString(valueCount));
        writer.write("
      ");
        writer.write("
      ");
      
        for (Entry entry : lruEntries.values()) {
          if (entry.currentEditor != null) {
            writer.write(DIRTY + ' ' + entry.key + '
      ');
          } else {
            writer.write(CLEAN + ' ' + entry.key + entry.getLengths() + '
      ');
          }
        }
      } finally {
        writer.close();
      }
      
      if (journalFile.exists()) {
        renameTo(journalFile, journalFileBackup, true);
      }
      renameTo(journalFileTmp, journalFile, false);
      journalFileBackup.delete();
      
      journalWriter = new BufferedWriter(
          new OutputStreamWriter(new FileOutputStream(journalFile, true), Util.US_ASCII));
      }
      

      可以看到首先构建一个journal.tmp文件,然后写入文件头(5行),然后遍历lruEntries(lruEntries =
      new LinkedHashMap<String, Entry>(0, 0.75f, true);
      ),当然我们这里没有任何数据。接下来将tmp文件重命名为journal文件。

    • 如果存在

      如果已经存在,那么调用readJournal

      private void readJournal() throws IOException {
      StrictLineReader reader = new StrictLineReader(new FileInputStream(journalFile), Util.US_ASCII);
      try {
        String magic = reader.readLine();
        String version = reader.readLine();
        String appVersionString = reader.readLine();
        String valueCountString = reader.readLine();
        String blank = reader.readLine();
        if (!MAGIC.equals(magic)
            || !VERSION_1.equals(version)
            || !Integer.toString(appVersion).equals(appVersionString)
            || !Integer.toString(valueCount).equals(valueCountString)
            || !"".equals(blank)) {
          throw new IOException("unexpected journal header: [" + magic + ", " + version + ", "
              + valueCountString + ", " + blank + "]");
        }
      
        int lineCount = 0;
        while (true) {
          try {
            readJournalLine(reader.readLine());
            lineCount++;
          } catch (EOFException endOfJournal) {
            break;
          }
        }
        redundantOpCount = lineCount - lruEntries.size();
      
        // If we ended on a truncated line, rebuild the journal before appending to it.
        if (reader.hasUnterminatedLine()) {
          rebuildJournal();
        } else {
          journalWriter = new BufferedWriter(new OutputStreamWriter(
              new FileOutputStream(journalFile, true), Util.US_ASCII));
        }
      } finally {
        Util.closeQuietly(reader);
      }
      }

      首先校验文件头,接下来调用readJournalLine按行读取内容。我们来看看readJournalLine中的操作。

      private void readJournalLine(String line) throws IOException {
      int firstSpace = line.indexOf(' ');
      if (firstSpace == -1) {
        throw new IOException("unexpected journal line: " + line);
      }
      
      int keyBegin = firstSpace + 1;
      int secondSpace = line.indexOf(' ', keyBegin);
      final String key;
      if (secondSpace == -1) {
        key = line.substring(keyBegin);
        if (firstSpace == REMOVE.length() && line.startsWith(REMOVE)) {
          lruEntries.remove(key);
          return;
        }
      } else {
        key = line.substring(keyBegin, secondSpace);
      }
      
      Entry entry = lruEntries.get(key);
      if (entry == null) {
        entry = new Entry(key);
        lruEntries.put(key, entry);
      }
      
      if (secondSpace != -1 && firstSpace == CLEAN.length() && line.startsWith(CLEAN)) {
        String[] parts = line.substring(secondSpace + 1).split(" ");
        entry.readable = true;
        entry.currentEditor = null;
        entry.setLengths(parts);
      } else if (secondSpace == -1 && firstSpace == DIRTY.length() && line.startsWith(DIRTY)) {
        entry.currentEditor = new Editor(entry);
      } else if (secondSpace == -1 && firstSpace == READ.length() && line.startsWith(READ)) {
        // This work was already done by calling lruEntries.get().
      } else {
        throw new IOException("unexpected journal line: " + line);
      }
      }

      大家可以回忆下:每个记录至少有一个空格,有的包含两个空格。首先,拿到key,如果是REMOVE的记录呢,会调用lruEntries.remove(key);

      如果不是REMOVE记录,继续往下,如果该key没有加入到lruEntries,则创建并且加入。

      接下来,如果是CLEAN开头的合法记录,初始化entry,设置readable=true,currentEditor为null,初始化长度等。

      如果是DIRTY,设置currentEditor对象。

      如果是READ,那么直接不管。

      ok,经过上面这个过程,大家回忆下我们的记录格式,一般DIRTY不会单独出现,会和REMOVE、CLEAN成对出现(正常操作);也就是说,经过上面这个流程,基本上加入到lruEntries里面的只有CLEAN且没有被REMOVE的key。

      好了,回到readJournal方法,在我们按行读取的时候,会记录一下lineCount,然后最后给redundantOpCount赋值,这个变量记录的应该是没用的记录条数(文件的行数-真正可以的key的行数)。

      最后,如果读取过程中发现journal文件有问题,则重建journal文件。没有问题的话,初始化下journalWriter,关闭reader。

      readJournal完成了,会继续调用processJournal()这个方法内部:

      private void processJournal() throws IOException {
      deleteIfExists(journalFileTmp);
      for (Iterator<Entry> i = lruEntries.values().iterator(); i.hasNext(); ) {
        Entry entry = i.next();
        if (entry.currentEditor == null) {
          for (int t = 0; t < valueCount; t++) {
            size += entry.lengths[t];
          }
        } else {
          entry.currentEditor = null;
          for (int t = 0; t < valueCount; t++) {
            deleteIfExists(entry.getCleanFile(t));
            deleteIfExists(entry.getDirtyFile(t));
          }
          i.remove();
        }
      }
      }

      统计所有可用的cache占据的容量,赋值给size;对于所有非法DIRTY状态(就是DIRTY单独出现的)的entry,如果存在文件则删除,并且从lruEntries中移除。此时,剩的就真的只有CLEAN状态的key记录了。

    ok,到此就初始化完毕了,太长了,根本记不住,我带大家总结下上面代码。

    根据我们传入的dir,去找journal文件,如果找不到,则创建个,只写入文件头(5行)。
    如果找到,则遍历该文件,将里面所有的CLEAN记录的key,存到lruEntries中。

    这么长的代码,其实就两句话的意思。经过open以后,journal文件肯定存在了;lruEntries里面肯定有值了;size存储了当前所有的实体占据的容量;。


    四、存入缓存

    还记得,我们前面说过是怎么存的么?

    String key = generateKey(url);  
    DiskLruCache.Editor editor = mDiskLruCache.edit(key); 
    OuputStream os = editor.newOutputStream(0); 
    //...after op
    editor.commit();

    那么首先就是editor方法;

    /**
       * Returns an editor for the entry named {@code key}, or null if another
       * edit is in progress.
       */
      public Editor edit(String key) throws IOException {
        return edit(key, ANY_SEQUENCE_NUMBER);
      }
    
      private synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException {
          checkNotClosed();
        validateKey(key);
        Entry entry = lruEntries.get(key);
        if (expectedSequenceNumber != ANY_SEQUENCE_NUMBER && (entry == null
            || entry.sequenceNumber != expectedSequenceNumber)) {
          return null; // Snapshot is stale.
        }
        if (entry == null) {
          entry = new Entry(key);
          lruEntries.put(key, entry);
        } else if (entry.currentEditor != null) {
          return null; // Another edit is in progress.
        }
    
        Editor editor = new Editor(entry);
        entry.currentEditor = editor;
    
        // Flush the journal before creating files to prevent file leaks.
        journalWriter.write(DIRTY + ' ' + key + '
    ');
        journalWriter.flush();
        return editor;
      }

    首先验证key,可以必须是字母、数字、下划线、横线(-)组成,且长度在1-120之间。

    然后通过key获取实体,因为我们是存,只要不是正在编辑这个实体,理论上都能返回一个合法的editor对象。

    所以接下来判断,如果不存在,则创建一个Entry加入到lruEntries中(如果存在,直接使用),然后为entry.currentEditor进行赋值为new Editor(entry);,最后在journal文件中写入一条DIRTY记录,代表这个文件正在被操作。

    注意,如果entry.currentEditor != null不为null的时候,意味着该实体正在被编辑,会retrun null ;

    拿到editor对象以后,就是去调用newOutputStream去获得一个文件输入流了。

    /**
         * Returns a new unbuffered output stream to write the value at
         * {@code index}. If the underlying output stream encounters errors
         * when writing to the filesystem, this edit will be aborted when
         * {@link #commit} is called. The returned output stream does not throw
         * IOExceptions.
         */
        public OutputStream newOutputStream(int index) throws IOException {
          if (index < 0 || index >= valueCount) {
            throw new IllegalArgumentException("Expected index " + index + " to "
                    + "be greater than 0 and less than the maximum value count "
                    + "of " + valueCount);
          }
          synchronized (DiskLruCache.this) {
            if (entry.currentEditor != this) {
              throw new IllegalStateException();
            }
            if (!entry.readable) {
              written[index] = true;
            }
            File dirtyFile = entry.getDirtyFile(index);
            FileOutputStream outputStream;
            try {
              outputStream = new FileOutputStream(dirtyFile);
            } catch (FileNotFoundException e) {
              // Attempt to recreate the cache directory.
              directory.mkdirs();
              try {
                outputStream = new FileOutputStream(dirtyFile);
              } catch (FileNotFoundException e2) {
                // We are unable to recover. Silently eat the writes.
                return NULL_OUTPUT_STREAM;
              }
            }
            return new FaultHidingOutputStream(outputStream);
          }
        }
    
    

    首先校验index是否在valueCount范围内,一般我们使用都是一个key对应一个文件所以传入的基本都是0。接下来就是通过entry.getDirtyFile(index);拿到一个dirty File对象,为什么叫dirty file呢,其实就是个中转文件,文件格式为key.index.tmp。
    将这个文件的FileOutputStream通过FaultHidingOutputStream封装下传给我们。

    最后,别忘了我们通过os写入数据以后,需要调用commit方法。

    public void commit() throws IOException {
          if (hasErrors) {
            completeEdit(this, false);
            remove(entry.key); // The previous entry is stale.
          } else {
            completeEdit(this, true);
          }
          committed = true;
        }

    首先通过hasErrors判断,是否有错误发生,如果有调用completeEdit(this, false)且调用remove(entry.key);。如果没有就调用completeEdit(this, true);

    那么这里这个hasErrors哪来的呢?还记得上面newOutputStream的时候,返回了一个os,这个os是FileOutputStream,但是经过了FaultHidingOutputStream封装么,这个类实际上就是重写了FilterOutputStream的write相关方法,将所有的IOException给屏蔽了,如果发生IOException就将hasErrors赋值为true.

    这样的设计还是很nice的,否则直接将OutputStream返回给用户,如果出错没法检测,还需要用户手动去调用一些操作。

    接下来看completeEdit方法。

    private synchronized void completeEdit(Editor editor, boolean success) throws IOException {
        Entry entry = editor.entry;
        if (entry.currentEditor != editor) {
          throw new IllegalStateException();
        }
    
        // If this edit is creating the entry for the first time, every index must have a value.
        if (success && !entry.readable) {
          for (int i = 0; i < valueCount; i++) {
            if (!editor.written[i]) {
              editor.abort();
              throw new IllegalStateException("Newly created entry didn't create value for index " + i);
            }
            if (!entry.getDirtyFile(i).exists()) {
              editor.abort();
              return;
            }
          }
        }
    
        for (int i = 0; i < valueCount; i++) {
          File dirty = entry.getDirtyFile(i);
          if (success) {
            if (dirty.exists()) {
              File clean = entry.getCleanFile(i);
              dirty.renameTo(clean);
              long oldLength = entry.lengths[i];
              long newLength = clean.length();
              entry.lengths[i] = newLength;
              size = size - oldLength + newLength;
            }
          } else {
            deleteIfExists(dirty);
          }
        }
    
        redundantOpCount++;
        entry.currentEditor = null;
        if (entry.readable | success) {
          entry.readable = true;
          journalWriter.write(CLEAN + ' ' + entry.key + entry.getLengths() + '
    ');
          if (success) {
            entry.sequenceNumber = nextSequenceNumber++;
          }
        } else {
          lruEntries.remove(entry.key);
          journalWriter.write(REMOVE + ' ' + entry.key + '
    ');
        }
        journalWriter.flush();
    
        if (size > maxSize || journalRebuildRequired()) {
          executorService.submit(cleanupCallable);
        }
      }

    首先判断if (success && !entry.readable)是否成功,且是第一次写入(如果以前这个记录有值,则readable=true),内部的判断,我们都不会走,因为written[i]在newOutputStream的时候被写入true了。而且正常情况下,getDirtyFile是存在的。

    接下来,如果成功,将dirtyFile 进行重命名为 cleanFile,文件名为:key.index。然后刷新size的长度。如果失败,则删除dirtyFile.

    接下来,如果成功或者readable为true,将readable设置为true,写入一条CLEAN记录。如果第一次提交且失败,那么就会从lruEntries.remove(key),写入一条REMOVE记录。

    写入缓存,肯定要控制下size。于是最后,判断是否超过了最大size,或者需要重建journal文件,什么时候需要重建呢?

     private boolean journalRebuildRequired() {
        final int redundantOpCompactThreshold = 2000;
        return redundantOpCount >= redundantOpCompactThreshold //
            && redundantOpCount >= lruEntries.size();
      }

    如果redundantOpCount达到2000,且超过了lruEntries.size()就重建,这里就可以看到redundantOpCount的作用了。防止journal文件过大。

    ok,到此我们的存入缓存就分析完成了。再次总结下,首先调用editor,拿到指定的dirtyFile的OutputStream,你可以尽情的进行写操作,写完以后呢,记得调用commit.
    commit中会检测是你是否发生IOException,如果没有发生,则将dirtyFile->cleanFile,将readable=true,写入CLEAN记录。如果发生错误,则删除dirtyFile,从lruEntries中移除,然后写入一条REMOVE记录。


    五、读取缓存

    DiskLruCache.Snapshot snapShot = mDiskLruCache.get(key);  
    if (snapShot != null) {  
      InputStream is = snapShot.getInputStream(0);  
    }

    那么首先看get方法:

    public synchronized Snapshot get(String key) throws IOException {
        checkNotClosed();
        validateKey(key);
        Entry entry = lruEntries.get(key);
        if (entry == null) {
          return null;
        }
    
        if (!entry.readable) {
          return null;
        }
    
        // Open all streams eagerly to guarantee that we see a single published
        // snapshot. If we opened streams lazily then the streams could come
        // from different edits.
        InputStream[] ins = new InputStream[valueCount];
        try {
          for (int i = 0; i < valueCount; i++) {
            ins[i] = new FileInputStream(entry.getCleanFile(i));
          }
        } catch (FileNotFoundException e) {
          // A file must have been deleted manually!
          for (int i = 0; i < valueCount; i++) {
            if (ins[i] != null) {
              Util.closeQuietly(ins[i]);
            } else {
              break;
            }
          }
          return null;
        }
    
        redundantOpCount++;
        journalWriter.append(READ + ' ' + key + '
    ');
        if (journalRebuildRequired()) {
          executorService.submit(cleanupCallable);
        }
    
        return new Snapshot(key, entry.sequenceNumber, ins, entry.lengths);
      }

    get方法比较简单,如果取到的为null,或者readable=false,则返回null.否则将cleanFile的FileInputStream进行封装返回Snapshot,且写入一条READ语句。
    然后getInputStream就是返回该FileInputStream了。

    好了,到此,我们就分析完成了创建DiskLruCache,存入缓存和取出缓存的源码。

    除此以外,还有一些别的方法我们需要了解的。


    六、其他方法

    remove()

    /**
       * Drops the entry for {@code key} if it exists and can be removed. Entries
       * actively being edited cannot be removed.
       *
       * @return true if an entry was removed.
       */
      public synchronized boolean remove(String key) throws IOException {
        checkNotClosed();
        validateKey(key);
        Entry entry = lruEntries.get(key);
        if (entry == null || entry.currentEditor != null) {
          return false;
        }
    
        for (int i = 0; i < valueCount; i++) {
          File file = entry.getCleanFile(i);
          if (file.exists() && !file.delete()) {
            throw new IOException("failed to delete " + file);
          }
          size -= entry.lengths[i];
          entry.lengths[i] = 0;
        }
    
        redundantOpCount++;
        journalWriter.append(REMOVE + ' ' + key + '
    ');
        lruEntries.remove(key);
    
        if (journalRebuildRequired()) {
          executorService.submit(cleanupCallable);
        }
    
        return true;
      }

    如果实体存在且不在被编辑,就可以直接进行删除,然后写入一条REMOVE记录。

    与open对应还有个remove方法,大家在使用完成cache后可以手动关闭。


    close()

    /** Closes this cache. Stored values will remain on the filesystem. */
      public synchronized void close() throws IOException {
        if (journalWriter == null) {
          return; // Already closed.
        }
        for (Entry entry : new ArrayList<Entry>(lruEntries.values())) {
          if (entry.currentEditor != null) {
            entry.currentEditor.abort();
          }
        }
        trimToSize();
        journalWriter.close();
        journalWriter = null;
      }
    

    关闭前,会判断所有正在编辑的实体,调用abort方法,最后关闭journalWriter。至于abort方法,其实我们分析过了,就是存储失败的时候的逻辑:

    public void abort() throws IOException {
          completeEdit(this, false);
        }

    到此,我们的整个源码分析就结束了。可以看到DiskLruCache,利用一个journal文件,保证了保证了cache实体的可用性(只有CLEAN的可用),且获取文件的长度的时候可以通过在该文件的记录中读取。利用FaultHidingOutputStream对FileOutPutStream很好的对写入文件过程中是否发生错误进行捕获,而不是让用户手动去调用出错后的处理方法。其内部的很多细节都很值得推敲。

    不过也可以看到,存取的操作不是特别的容易使用,需要大家自己去操作文件流,但在存储比较小的数据的时候(不存在内存问题),很多时候还是希望有类似put(key,value),getAsT(key)等方法直接使用。我看了ASimpleCache 提供的API属于比较好用的了。于是萌生想法,对DiskLruCache公开的API进行扩展,对外除了原有的存取方式以外,提供类似ASimpleCache那样比较简单的API用于存储,而内部的核心实现,依然是DiskLruCache原本的。

    github地址: base-diskcache,欢迎star,fork。

    欢迎关注我的微博:
    http://weibo.com/u/3165018720


    群号:463081660,欢迎入群

    微信公众号:hongyangAndroid
    (欢迎关注,第一时间推送博文信息)

    版权声明:本文为博主原创文章,未经博主允许不得转载。

  • 相关阅读:
    RPi.GPIO
    Linux(Ubuntu)下查看摄像头是否挂载及挂载的USB端口号
    python--python脚本中调用shell命令
    pip
    MQTT
    Ubuntu安装pip
    python Opencv开启USB摄像头并录像保存
    视频 客服
    Android Test
    MAC系统下ADB入门与简单使用
  • 原文地址:https://www.cnblogs.com/dingxiaoyue/p/4924832.html
Copyright © 2011-2022 走看看