Android DiskLruCache 源碼解析 硬盤緩存的絕佳方案

jopen 8年前發布 | 11K 次閱讀 Android開發 移動開發

轉載請標明出處:
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("\n");
      writer.write(VERSION_1);
      writer.write("\n");
      writer.write(Integer.toString(appVersion));
      writer.write("\n");
      writer.write(Integer.toString(valueCount));
      writer.write("\n");
      writer.write("\n");
    
      for (Entry entry : lruEntries.values()) {
        if (entry.currentEditor != null) {
          writer.write(DIRTY + ' ' + entry.key + '\n');
        } else {
          writer.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n');
        }
      }
    } 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 + '\n');
    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() + '\n');
      if (success) {
        entry.sequenceNumber = nextSequenceNumber++;
      }
    } else {
      lruEntries.remove(entry.key);
      journalWriter.write(REMOVE + ' ' + entry.key + '\n');
    }
    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 + '\n');
    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 + '\n');
    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
(歡迎關注,第一時間推送博文信息)

來自: http://blog.csdn.net//lmj623565791/article/details/47251585

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!