当前位置:网站首页>SharedPreferences source code analysis

SharedPreferences source code analysis

2022-07-06 04:21:00 jthou20121212

SharedPreferences It is a persistent storage scheme for storing a small number of key value pairs provided by the system , Characteristic is API It is relatively simple and convenient to use , But it also has some defects. If it is not used properly, it may lead to jamming and even ANR also SharedPreferences Multi process is not supported

Have a look first SharedPreferences The acquisition process of

private static ArrayMap<String, ArrayMap<File, SharedPreferencesImpl>> sSharedPrefsCache;

private ArrayMap<String, File> mSharedPrefsPaths;

// android.app.ContextImpl#getSharedPreferences(java.lang.String, int)
// SharedPreferences  According to the incoming  name  Generate a corresponding  xml  file 
//  The data stored according to the key value pair is here  xml  In the document 
public SharedPreferences getSharedPreferences(String name, int mode) {
    
    //  Code ellipsis  ..

    File file;
    synchronized (ContextImpl.class) {
    
        if (mSharedPrefsPaths == null) {
    
            mSharedPrefsPaths = new ArrayMap<>();
        }
      	// name  And  file  Mapping 
        file = mSharedPrefsPaths.get(name);
        if (file == null) {
    
          	//  Create a file by name , The file name is passed in  name
            file = getSharedPreferencesPath(name);
            mSharedPrefsPaths.put(name, file);
        }
    }
    return getSharedPreferences(file, mode);
}

public SharedPreferences getSharedPreferences(File file, int mode) {
    
    SharedPreferencesImpl sp;
    synchronized (ContextImpl.class) {
    
      	//  See if there is 
        final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
        sp = cache.get(file);
        if (sp == null) {
    
            //  Code ellipsis  ..
            sp = new SharedPreferencesImpl(file, mode);
            cache.put(file, sp);
            return sp;
        }
    }
  	//  If used  MODE_MULTI_PROCESS  Mark every time you get  SharedPreferences  The file will be re read when the instance 
    if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
        getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
    
        sp.startReloadIfChangedUnexpectedly();
    }
    return sp;
}

private ArrayMap<File, SharedPreferencesImpl> getSharedPreferencesCacheLocked() {
    
    if (sSharedPrefsCache == null) {
    
        sSharedPrefsCache = new ArrayMap<>();
    }

    final String packageName = getPackageName();
  	//  Package name and  App  All documents and  SharedPreferences  Mapping of interface instances 
    ArrayMap<File, SharedPreferencesImpl> packagePrefs = sSharedPrefsCache.get(packageName);
    if (packagePrefs == null) {
    
        packagePrefs = new ArrayMap<>();
        sSharedPrefsCache.put(packageName, packagePrefs);
    }

    return packagePrefs;
}

SharedPreferences It's an interface. What we get is its implementation class SharedPreferencesImpl The process is also relatively simple, first through the incoming name Get the file and create one through the file and the incoming operation mode of the file SharedPreferencesImpl And cache it , stay SharedPreferencesImpl In the construction method of, the data in the file will be read and mapped to a map in , and sSharedPrefsCache It is static and does not provide a release method, so App All data in the memory will always be in the memory after reading

SharedPreferencesImpl(File file, int mode) {
    
  	//  Source file for storing data 
    mFile = file;
  	//  Backup file 
    mBackupFile = makeBackupFile(file);
    mMode = mode;
  	//  Mark whether the read file operation is completed 
    mLoaded = false;
    mMap = null;
    mThrowable = null;
  	//  Start a thread to read the file 
    startLoadFromDisk();
}

private void startLoadFromDisk() {
    
  	//  The flag bit indicates whether the loading is complete 
  	//  The lock used is  mLock
    synchronized (mLock) {
    
      	//  Set to  false 
        mLoaded = false;
    }
    new Thread("SharedPreferencesImpl-load") {
    
        public void run() {
    
            loadFromDisk();
        }
    }.start();
}

private void loadFromDisk() {
    
    synchronized (mLock) {
    
        if (mLoaded) {
    
            return;
        }
        //  Every time the instance object is obtained, if the backup file exists, it indicates that an exception occurred when saving the data last time, and the file has been damaged 
        //  Then delete the source file and use the backup file. If an exception occurs, only the last saved data will be lost 
        if (mBackupFile.exists()) {
    
            mFile.delete();
            mBackupFile.renameTo(mFile);
        }
    }

    Map<String, Object> map = null;
    StructStat stat = null;
    Throwable thrown = null;
    try {
    
        stat = Os.stat(mFile.getPath());
        if (mFile.canRead()) {
    
            BufferedInputStream str = null;
            try {
    
                str = new BufferedInputStream(
                        new FileInputStream(mFile), 16 * 1024);
                //  Read the file and save the data to  map  According to the  key  When getting data, it is from this  map  Instead of reading files every time 
                map = (Map<String, Object>) XmlUtils.readMapXml(str);
            } catch (Exception e) {
    
                Log.w(TAG, "Cannot read " + mFile.getAbsolutePath(), e);
            } finally {
    
                IoUtils.closeQuietly(str);
            }
        }
    } catch (ErrnoException e) {
    
        // An errno exception means the stat failed. Treat as empty/non-existing by
        // ignoring.
    } catch (Throwable t) {
    
        thrown = t;
    }

    synchronized (mLock) {
    
        mLoaded = true;
        mThrowable = thrown;
        try {
    
            //  If there is no exception in the process of reading the file 
            //  The saved file information is used to compare whether the file has been updated since the last time the file was read 
            if (thrown == null) {
    
                if (map != null) {
    
                    mMap = map;
                  	//  File update time 
                    mStatTimestamp = stat.st_mtim;
                  	//  file size 
                    mStatSize = stat.st_size;
                } else {
    
                    mMap = new HashMap<>();
                }
            }
            // In case of a thrown exception, we retain the old map. That allows
            // any open editors to commit and store updates.
        } catch (Throwable t) {
    
            mThrowable = t;
        } finally {
    
          	//  Notify all waiting threads of the end of file reading 
            mLock.notifyAll();
        }
    }
}

Creating SharedPreferences Object will open a thread to read the file , The content will be saved to a map According to key The data is obtained from this map Instead of reading files every time

public String getString(String key, @Nullable String defValue) {
    
    synchronized (mLock) {
    
        awaitLoadedLocked();
        String v = (String)mMap.get(key);
        return v != null ? v : defValue;
    }
}

private void awaitLoadedLocked() {
    
    while (!mLoaded) {
    
        try {
    
          	//  If the reading of the file is not finished yet, wait for the completion 
            mLock.wait();
        } catch (InterruptedException unused) {
    
        }
    }
    if (mThrowable != null) {
    
        throw new IllegalStateException(mThrowable);
    }
}

SharedPreferences When the object is created, it will start a sub thread to read the file data to map in , Reading files is a time-consuming operation , If the main thread does not finish reading the file, it will fetch the data , The main thread will block and wait for the file reading thread to end . So a little trick can save waiting time , Since the read operation will wait for the end of the file reading process and the file reading is performed on another thread , So you can read the file first, then do something else, and then get the data

//  First, let sp Go to another thread to load 
SharedPreferences sp = getSharedPreferences("test", MODE_PRIVATE);
//  Do a bunch of other things 
setContentView(testSpJson);
// ...

// OK, At this time, it is estimated that it has been loaded , Even if it's not over , We also did something when we should have waited !
String testValue = sp.getString("testKey", null);

This part of the code comes from Please don't abuse SharedPreference

The read operation is read directly map The write operation is more complex than the read operation , Need to go through first edit Method to get Editor object , It is also an interface implementation class EditorImpl Every time you call edit Method creates a EditorImpl object , Multiple write operations will first record the operation and call commit/apply Method and submit again , So try to write more than once before commit/apply Avoid writing every time commit/apply therefore SPUtils This kind of tool class is relatively simple, but it is actually not recommended

public final class EditorImpl implements Editor {
    
  	//  Operations on data will be saved here 
    private final Object mEditorLock = new Object();

    @GuardedBy("mEditorLock")
    private final Map<String, Object> mModified = new HashMap<>();

    @GuardedBy("mEditorLock")
    private boolean mClear = false;

    @Override
    public Editor putString(String key, @Nullable String value) {
    
        synchronized (mEditorLock) {
    
            mModified.put(key, value);
            return this;
        }
    }

    @Override
    public Editor remove(String key) {
    
        synchronized (mEditorLock) {
    
          	//  The removal operation is quite special, which will put  value  Set to judge when writing to the disk 
            mModified.put(key, this);
            return this;
        }
    }

    @Override
    public Editor clear() {
    
        synchronized (mEditorLock) {
    
          	//  Tag called  clear  Method 
            mClear = true;
            return this;
        }
    }
}    

about putXxx All write operations are operational mModified about remove The operation is to put value Set to this about clear The operation is to set the flag bit mClear by true And then call commit/apply Method to write the changes to the file , The difference lies in commit It's synchronous apply It's asynchronous

public boolean commit() {
    
  	//  Commit changes to memory 
    MemoryCommitResult mcr = commitToMemory();

  	//  Write memory changes to disk 
    SharedPreferencesImpl.this.enqueueDiskWrite(
        mcr, null /* sync write on this thread okay */);
    try {
    
      	//  Wait for the disk write to complete 
        mcr.writtenToDiskLatch.await();
    } catch (InterruptedException e) {
    
        return false;
    }
  	//  Callback monitor 
    notifyListeners(mcr);
  	//  Return the write result 
    return mcr.writeToDiskResult;
}

private MemoryCommitResult commitToMemory() {
    
    long memoryStateGeneration;
    boolean keysCleared = false;
    List<String> keysModified = null;
    Set<OnSharedPreferenceChangeListener> listeners = null;
    Map<String, Object> mapToWriteToDisk;

    synchronized (SharedPreferencesImpl.this.mLock) {
    
        //  If there are other threads writing, copy a copy of the data 
        if (mDiskWritesInFlight > 0) {
    
            mMap = new HashMap<String, Object>(mMap);
        }
        mapToWriteToDisk = mMap;
        //  Number of threads performing write operations 
        mDiskWritesInFlight++;

        boolean hasListeners = mListeners.size() > 0;
        if (hasListeners) {
    
            keysModified = new ArrayList<String>();
            listeners = new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());
        }

        synchronized (mEditorLock) {
    
            boolean changesMade = false;
            //  If called  clear  Method  
            if (mClear) {
    
                //  Data is not empty, clear data 
                if (!mapToWriteToDisk.isEmpty()) {
    
                    //  Indicates that the data has changed 
                    changesMade = true;
                    mapToWriteToDisk.clear();
                }
                keysCleared = true;
                //  Reset  mClear  Mark 
                mClear = false;
            }
            //  Traverse  mModified  Judge whether there is any modification 
            for (Map.Entry<String, Object> e : mModified.entrySet()) {
    
                String k = e.getKey();
                Object v = e.getValue();
                //  If  value  yes  this  That is the  remove  operation 
                //  If you will  value  yes  null  Will perform  remove  operation 
                //  So if you call  putXxx  Will be a  key  Corresponding  value  Set to  null  This  key  It will also be removed  
                if (v == this || v == null) {
    
                    if (!mapToWriteToDisk.containsKey(k)) {
    
                        continue;
                    }
                    mapToWriteToDisk.remove(k);
                } else {
    
                    if (mapToWriteToDisk.containsKey(k)) {
    
                        Object existingValue = mapToWriteToDisk.get(k);
                        //  Only when the new value is different from the old value can it be replaced 
                        if (existingValue != null && existingValue.equals(v)) {
    
                            continue;
                        }
                    }
                    mapToWriteToDisk.put(k, v);
                }
                // mModified  If it is not empty, it is considered that the data has changed 
                changesMade = true;
                if (hasListeners) {
    
                    keysModified.add(k);
                }
            }

            //  Empty  mModified
            mModified.clear();

            //  If the data changes, add one to the data version number in memory 
            if (changesMade) {
    
                mCurrentMemoryStateGeneration++;
            }

            memoryStateGeneration = mCurrentMemoryStateGeneration;
        }
    }
    //  Create a  MemoryCommitResult  Object, which is the result of writing data changes to memory 
    // memoryStateGeneration  Indicates the version number of the current memory data 
    // keysCleared  Indicates whether the emptying operation has been performed 
    // keysModified  It's all  value  Something has changed  key
    // listeners  It's listening. 
    // mapToWriteToDisk  Is the data in memory after a series of change operations 
  	//  stay  MemoryCommitResult  The constructor of will initialize an initial value of  1  Of  CountDownLatch  object 
    return new MemoryCommitResult(memoryStateGeneration, keysCleared, keysModified,
            listeners, mapToWriteToDisk);
}

// commit/apply  Will call this method 
// commit  Invocation time  postWriteRunnable  by  null
// apply  Invocation time  postWriteRunnable  Not empty  
private void enqueueDiskWrite(final MemoryCommitResult mcr,
                              final Runnable postWriteRunnable) {
    
    //  Indicates from  commit  still  apply  Call to  
    final boolean isFromSyncCommit = (postWriteRunnable == null);
		//  Write data in memory to a file 
    final Runnable writeToDiskRunnable = new Runnable() {
    
            @Override
            public void run() {
    
                synchronized (mWritingToDiskLock) {
    
                  	//  Write data in memory to disk 
                    writeToFile(mcr, isFromSyncCommit);
                }
                synchronized (mLock) {
    
                  	//  Write operation is completed, and the number of operation threads is reduced by one 
                    mDiskWritesInFlight--;
                }
              	//  If  postWriteRunnable  Not for  null  perform  postWriteRunnable
              	//  from  commit  Call in  postWriteRunnable  by  null
              	//  from  apply  Call in  postWriteRunnable  Not for  null
                if (postWriteRunnable != null) {
    
                    postWriteRunnable.run();
                }
            }
        };

    //  If it's from  commmit  Call to 
    if (isFromSyncCommit) {
    
        boolean wasEmpty = false;
        synchronized (mLock) {
    
            //  If only the current thread is performing write operations 
            wasEmpty = mDiskWritesInFlight == 1;
        }
        //  Then write the file directly in the current thread 
        if (wasEmpty) {
    
            writeToDiskRunnable.run();
            return;
        }
    }

    //  If it is finished after the current thread executes file writing 
    //  If it is  apply  Method or  commit  Method, but if multiple threads are writing files, they are added to the queue to execute 
    QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}

public static void queue(Runnable work, boolean shouldDelay) {
    
    // QueuedWorkHandler  object , its  looper  Is in  HandlerThread  Of  run  Created in the , So the message sent to it will be executed in this thread 
    Handler handler = getHandler();

    synchronized (sLock) {
    
        //  Join the queue 
        sWork.add(work);
        // commit  There is no need to delay entering  apply  Entering requires a delay 
        if (shouldDelay && sCanDelay) {
    
            handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY);
        } else {
    
          	//  Send a message to  handler 
            handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);
        }
    }
}

// QueuedWorkHandler  received  MSG_RUN  The message will be executed immediately after  processPendingWork  Method 
private static void processPendingWork() {
    
    synchronized (sProcessingWork) {
    
        LinkedList<Runnable> work;

        synchronized (sLock) {
    
            work = (LinkedList<Runnable>) sWork.clone();
            sWork.clear();

            // Remove all msg-s as all work will be processed now
            getHandler().removeMessages(QueuedWorkHandler.MSG_RUN);
        }

        if (work.size() > 0) {
    
            //  Execute all... In sequence  runnable 
            for (Runnable w : work) {
    
                w.run();
            }
        }
    }
}

stay processPendingWork Method will execute the... In the queue in turn runnable That's what's coming in from above writeToDiskRunnable In this method, the real operation of writing to the disk will be performed writeToFile

private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
    
    //  Code ellipsis  ..
    boolean fileExists = mFile.exists();
    //  Source file exists 
    if (fileExists) {
    
        boolean needsWrite = false;

        //  Compare the disk data version with the memory data version. Only when the disk data version is lower than the memory data version, you need to write 
        if (mDiskStateGeneration < mcr.memoryStateGeneration) {
    
            //  If it is  commit  You need to write 
            if (isFromSyncCommit) {
    
                needsWrite = true;
            } else {
    
                synchronized (mLock) {
    
                    //  If it is  apply  Comparing the current memory data version with the memory data version to be written to the file requires writing 
                    //  Inconsistency indicates that there are new data changes waiting for the next write 
                    if (mCurrentMemoryStateGeneration == mcr.memoryStateGeneration) {
    
                        needsWrite = true;
                    }
                }
            }
        }

        if (!needsWrite) {
    
            //  Write only when the data really changes. Avoid meaningless writing 
            mcr.setDiskWriteResult(false, true);
            return;
        }

        boolean backupFileExists = mBackupFile.exists();
        //  If the backup file does not exist 
        if (!backupFileExists) {
    
            //  Name the source file backup file , Return if failed 
            if (!mFile.renameTo(mBackupFile)) {
    
                Log.e(TAG, "Couldn't rename file " + mFile
                      + " to backup file " + mBackupFile);
                mcr.setDiskWriteResult(false, false);
                return;
            }
        } else {
    
            //  If there is a backup file, delete the source file because a new file will be created immediately to write new data 
            mFile.delete();
        }
    }

    try {
    
        //  Omit writing data in memory to source file code  ..

        //  Write file successfully delete backup file 
        mBackupFile.delete();

        if (DEBUG) {
    
            deleteTime = System.currentTimeMillis();
        }

        //  Record the disk data version number 
        mDiskStateGeneration = mcr.memoryStateGeneration;

        //  Write successfully 
        mcr.setDiskWriteResult(true, true);

        //  Code ellipsis  ..

        return;
    } catch (XmlPullParserException e) {
    
        Log.w(TAG, "writeToFile: Got exception:", e);
    } catch (IOException e) {
    
        Log.w(TAG, "writeToFile: Got exception:", e);
    }

    //  If the write is successful, the above will return the write failure to delete the source file 
  	//  If an exception occurs in the next construction  SharedPreferencesImpl  Object will rename the backup file to the source file 
    if (mFile.exists()) {
    
        if (!mFile.delete()) {
    
            Log.e(TAG, "Couldn't clean up partially-written file " + mFile);
        }
    }
    mcr.setDiskWriteResult(false, false);
}

Whether the write succeeds or fails, it will call setDiskWriteResult Method

void setDiskWriteResult(boolean wasWritten, boolean result) {
    
    this.wasWritten = wasWritten;
    writeToDiskResult = result;
  	//  Release cause call  writtenToDiskLatch.await  Blocking waiting threads 
    writtenToDiskLatch.countDown();
}

call setDiskWriteResult Later, because of the call commit And the blocked thread is released , So let's see apply

public void apply() {
    
    // commit  We have analyzed 
    final MemoryCommitResult mcr = commitToMemory();
    final Runnable awaitCommit = new Runnable() {
    
            @Override
            public void run() {
    
                try {
    
                    mcr.writtenToDiskLatch.await();
                } catch (InterruptedException ignored) {
    
                }
            }
        };

    //  take  awaitCommit  Join the queue  awaitCommit  One thing is to wait for the file to be written 
    QueuedWork.addFinisher(awaitCommit);

    Runnable postWriteRunnable = new Runnable() {
    
        @Override
        public void run() {
    
            //  perform  awaitCommit  Wait for the file to be written 
            awaitCommit.run();
            //  remove  awaitCommit
            QueuedWork.removeFinisher(awaitCommit);
        }
    };

    //  And  commit  Same call  enqueueDiskWrite  But the second parameter passed in here is not empty 
    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);

    //  Callback listening before writing to disk  
    notifyListeners(mcr);
}

private void enqueueDiskWrite(final MemoryCommitResult mcr,
                              final Runnable postWriteRunnable) {
    
    final Runnable writeToDiskRunnable = new Runnable() {
    
            @Override
            public void run() {
    
                synchronized (mWritingToDiskLock) {
    
                    writeToFile(mcr, isFromSyncCommit);
                }
                synchronized (mLock) {
    
                    mDiskWritesInFlight--;
                }
                if (postWriteRunnable != null) {
    
                    postWriteRunnable.run();
                }
            }
        };
  
		//  Code ellipsis  ..

  	//  Join the queue at  HandlerThread  In turn 
  	// writeToDiskRunnable  After writing to the disk in, it will be executed immediately  postWriteRunnable 
  	//  and  postWriteRunnable  Just wait for the disk write to complete. Now it is finished, so it is directly removed from the queue 
    QueuedWork.queue(writeToDiskRunnable, true);
}

stay Activity 、service The lifecycle methods of these components will call android.app.QueuedWork#waitToFinish Wait for all write operations to disk to complete , If there are multiple write operations or the file is relatively large, the waiting time will be relatively long, and a serious situation may be caused ANR

public static void waitToFinish() {
    
    //  Code ellipsis  ..

    try {
    
        while (true) {
    
            Runnable finisher;
            //  Take out each task in turn to perform 
            synchronized (sLock) {
    
                finisher = sFinishers.poll();
            }
            //  All tasks are completed and exit 
            if (finisher == null) {
    
                break;
            }
            //  Every task is waiting for the completion of the disk write operation, so the completion of the execution means that all the disk write operations are completed 
            finisher.run();
        }
    } finally {
    
        sCanDelay = true;
    }

    //  Code ellipsis  ..
}

For data changes, first pass edit How to get one EditorImpl object , The change operation is recorded in a map in , Emptying is using a boolean Mark , Calling commit/apply Then judge whether the emptying method is called. If yes, empty the data and then traverse map Record all changed data into memory , Now the latest data is in memory . about commit Method will determine whether there is only one thread changing the file. If there is only one thread, the disk write operation will be performed in the current thread , If there are multiple threads changing the file, then HandlerThread Perform disk write operations in , No matter which thread is executing the disk write, it will wait for the disk write to complete, so it is a synchronous write , Because it's in commit Method, wait for the disk write to complete . about apply The method is called awaitCommit Of Runnable Wait for the disk write to complete , And it will be added to a queue before it is executed , Remove after execution , And this awaitCommit The implementation and removal of is in another called postWriteRunnable Of Runnable Implemented in , This postWriteRunnable Another one is called writeToDiskRunnable Medium Runnable Implemented in , writeToDiskRunnable Is in HandlerThread Implemented in , It will wait for the disk write to complete before executing postWriteRunnable Will perform again awaitCommit and awaitCommit Only one thing I did was block the disk before it was written, so in apply In the execution logic of the method, after the disk is written runnable It's all finished , And in the Activity Service Some components will wait in the life cycle method awaitCommit The added queue waits for all disk write operations to complete , So although apply It is asynchronous operation, but it can also be done ANR, In a nutshell commit Is waiting for the write operation to complete in the current thread apply The asynchronous thread waits for the write operation to complete and apply Will wait for the write operation to complete runnable Join a queue , stay Activity、Service The component life cycle method will execute these queued waiting write operations in turn ruannble Execution completed . Finally, I will summarize some points :

  • Every time I read / Write operations are all full and the data will always be in memory and will not be released
  • Multi process is not supported MODE_MULTI_PROCESS The function of is to get SharedPreferences When the object is instantiated, it is judged that the file has changed and the data is re read to the memory
  • In the first acquisition SharedPreferences Object will read the contents of the file into memory. If you read the data before this action is completed, it will block waiting for the file to be read, which may cause a jam
  • stay Activity Service In the life cycle method of some components, waiting for all disk write operations to complete may cause ANR
  • adopt registerOnSharedPreferenceChangeListener Method registered listener for commit Method will call back after the disk is written, and apply Methods will call back after the data is written to memory, and they are all called back in the main thread
  • By successfully writing the backup source file before writing the file every time, deleting the backup file when writing fails, deleting the source file and naming the backup file as the source file, we can ensure that even if the file is damaged and the data is lost, only the last saved data will be lost

Reference and thanks

Please don't abuse SharedPreference

Android Source code analysis SharedPreferences

analyse SharedPreference apply Caused by the ANR problem

Android Don't abuse SharedPreferences( On )

Android Don't abuse SharedPreferences( Next )

Article to read SharedPreferences And a little thought

[Google] bye SharedPreferences hug Jetpack DataStore

reflection | The authorities have no power to return ?Android SharedPreferences Design and implementation

原网站

版权声明
本文为[jthou20121212]所创,转载请带上原文链接,感谢
https://yzsam.com/2022/187/202207060414507694.html