SharedPreferences源码解析

server/2024/11/14 13:19:37/

前言

文章中部分地方SharedPreferences会简写成SP,先抛出几个问题:

  • SP存储的是什么文件,存储在哪个位置?
  • SP是线程安全的吗?
  • SP是如何保证数据安全的?
  • 使用SP有哪些问题?
  • SP会把数据加载到内存中吗?
  • 首次使用SP和第二次使用SP,关于加载数据这块会有哪些不同?
  • 使用SP存储json会有问题吗?
  • SP有没有备份机制?
  • SP可以跨进程吗?

上面问题有笔者亲身经历过的面试题,也有网上找的,确实如果没有看过SP源码的话,第一次面对这些问题真的会一脸懵逼。下面我们结合源码看下SP,顺便也找找这些问题的答案。

关于SP一些基础的使用和问题可以看我以前的一篇文章:Android数据存储之SharedPreferences详细总结

基本使用

写入:

SharedPreferences settings = getSharedPreferences("test", MODE_PRIVATE);
SharedPreferences.Editor editor = settings.edit();
editor.putString("name", "张飞");
editor.putInt("age", 18);
//editor.commit();
editor.apply();

读取:

SharedPreferences settings = getSharedPreferences("test", MODE_PRIVATE);
String name = settings.getString("name","");
int age = settings.getInt("age",0);

源码分析

使用入口

首先我们去看SharedPreferences.java代码,SharedPreferences仅仅是一个接口,并没有实现相关的代码。那具体的实现在哪里呢?我们去看getSharedPreferences(“test”, MODE_PRIVATE)方法是怎么获取到SharedPreferences的

//ContextWrapper.java
public SharedPreferences getSharedPreferences(String name, int mode) {return mBase.getSharedPreferences(name, mode);
}

ContextWrapper交给了mBase调用getSharedPreferences,对Context体系比较熟悉的同学应该知道这个mBase是一个ContextImpl,我们直接去看ContextImpl

public SharedPreferences getSharedPreferences(String name, int mode) {// At least one application in the world actually passes in a null// name.  This happened to work because when we generated the file name// we would stringify it to "null.xml".  Nice.if (mPackageInfo.getApplicationInfo().targetSdkVersion <Build.VERSION_CODES.KITKAT) {if (name == null) {name = "null";}}File file;synchronized (ContextImpl.class) {if (mSharedPrefsPaths == null) {mSharedPrefsPaths = new ArrayMap<>();}//在SharedPreferences详细总结文章中有说SharedPreferences存储的是文件//这里就是查找或者生成那个文件,mSharedPrefsPaths是一个ArrayMap//用于缓存文件路径和文件file = mSharedPrefsPaths.get(name);if (file == null) {//没有找到,根据路径生成文件并存储在mSharedPrefsPaths中file = getSharedPreferencesPath(name);mSharedPrefsPaths.put(name, file);}}return getSharedPreferences(file, mode);
}

下面接着去看getSharedPreferences方法,传递进去了文件和使用SharedPreferences的模式

public SharedPreferences getSharedPreferences(File file, int mode) {//SharedPreferences真正的实现SharedPreferencesImpl sp;//线程安全的获取synchronized (ContextImpl.class) {//还是从缓存中取,文件为键,SharedPreferencesImpl为值final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();sp = cache.get(file);if (sp == null) {//检查mode,Android N及以上MODE_WORLD_READABLE和MODE_WORLD_WRITEABLE已经不支持checkMode(mode);if (getApplicationInfo().targetSdkVersion >= android.os.Build.VERSION_CODES.O) {if (isCredentialProtectedStorage()&& !getSystemService(UserManager.class).isUserUnlockingOrUnlocked(UserHandle.myUserId())) {throw new IllegalStateException("SharedPreferences in credential encrypted "+ "storage are not available until after user is unlocked");}}//为空的话创建并写入缓存sp = new SharedPreferencesImpl(file, mode);cache.put(file, sp);return sp;}}//Android 11及以上不支持使用MODE_MULTI_PROCESSif ((mode & Context.MODE_MULTI_PROCESS) != 0 ||getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {// If somebody else (some other process) changed the prefs// file behind our back, we reload it.  This has been the// historical (if undocumented) behavior.sp.startReloadIfChangedUnexpectedly();}return sp;
}

可见核心逻辑都在SharedPreferencesImpl,下面接着去看SharedPreferencesImpl的代码

SharedPreferencesImpl

SharedPreferencesImpl构造函数

SharedPreferencesImpl(File file, int mode) {//传入的对应路径的file赋值给mFilemFile = file;//看名字是一个备份用的文件mBackupFile = makeBackupFile(file);//记录模式mMode = mode;mLoaded = false;//mMap设置为空mMap = null;mThrowable = null;//从存储中加载startLoadFromDisk();
}private void startLoadFromDisk() {synchronized (mLock) {mLoaded = false;}new Thread("SharedPreferencesImpl-load") {public void run() {//new 了一个线程去加载文件loadFromDisk();}}.start();
}
private void loadFromDisk() {synchronized (mLock) {if (mLoaded) {return;}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 {//根据文件生成BufferedInputStream流,用于读取str = new BufferedInputStream(new FileInputStream(mFile), 16 * 1024);//XmlUtils从流中读取内容并写入map,会生成一个HashMapmap = (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;// It's important that we always signal waiters, even if we'll make// them fail with an exception. The try-finally is pretty wide, but// better safe than sorry.try {if (thrown == null) {if (map != null) {//在synchronized代码块中把上面加载好的map赋值给mMap//至此数据就从xml文件中读取到内存中了mMap = map;mStatTimestamp = stat.st_mtim;mStatSize = stat.st_size;} else {//大概是读取失败,赋值一个空的HashMapmMap = 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 {mLock.notifyAll();}}
}

总结:

  1. 关于XML解析这块我们只需要知道,XML解析方式有Dom,Sax,还有Pull解析。这里使用了Pull解析。
  2. xml读取是耗时的,如果我们在SP中存储大量的数据必然会导致耗时增加
  3. 首次会加载到内存中,第二次就不会了
  4. 在内存中存储格式为Map<String, Object>数据形式

edit()

SharedPreferences并不允许直接putInt,putString必须先通过edit()获取一个Editor,然后调用Editor的各种putInt,putString等方法,然后在调用apply或者commit方法执行存入操作。下面我们分析下edit()方法

public Editor edit() {// TODO: remove the need to call awaitLoadedLocked() when// requesting an editor.  will require some work on the// Editor, but then we should be able to do:////      context.getSharedPreferences(..).edit().putString(..).apply()//// ... all without blocking.synchronized (mLock) {//可以看下面代码,就是等待加载完成,也就是等待loadFromDisk执行完成awaitLoadedLocked();}//返回一个新建的EditorImplreturn new EditorImpl();
}private void awaitLoadedLocked() {if (!mLoaded) {// Raise an explicit StrictMode onReadFromDisk for this// thread, since the real read will be in a different// thread and otherwise ignored by StrictMode.BlockGuard.getThreadPolicy().onReadFromDisk();}while (!mLoaded) {try {mLock.wait();} catch (InterruptedException unused) {}}if (mThrowable != null) {throw new IllegalStateException(mThrowable);}
}

EditorImpl

Editor真正的实现是EditorImpl

public final class EditorImpl implements Editor {//Editor的锁private final Object mEditorLock = new Object();//修改的数据@GuardedBy("mEditorLock")private final Map<String, Object> mModified = new HashMap<>();@GuardedBy("mEditorLock")private boolean mClear = false;@Overridepublic Editor putString(String key, @Nullable String value) {//put 方法加锁synchronized (mEditorLock) {mModified.put(key, value);return this;}}//各种put方法,所有的put方法都类似都加锁...@Overridepublic Editor remove(String key) {//根据键移除一个,加锁synchronized (mEditorLock) {mModified.put(key, this);return this;}}@Overridepublic Editor clear() {//清空,加锁synchronized (mEditorLock) {mClear = true;return this;}}@Overridepublic void apply() {...}@Overridepublic void commit() {...}
}

apply

我们都知道Editor有两个写入数据的方法,一个是apply异步写入非阻塞,一个是commit是阻塞的。但是现实情况真的是这样吗,我们来看看。

@Override
public void apply() {final long startTime = System.currentTimeMillis();//执行commitToMemory,并返回一个MemoryCommitResult包括写入内存的结果final MemoryCommitResult mcr = commitToMemory();final Runnable awaitCommit = new Runnable() {@Overridepublic void run() {try {//MemoryCommitResult有一个writtenToDiskLatch//只有setDiskWriteResult方法执行的时候才会countDownmcr.writtenToDiskLatch.await();} catch (InterruptedException ignored) {}if (DEBUG && mcr.wasWritten) {Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration+ " applied after " + (System.currentTimeMillis() - startTime)+ " ms");}}};QueuedWork.addFinisher(awaitCommit);Runnable postWriteRunnable = new Runnable() {@Overridepublic void run() {awaitCommit.run();QueuedWork.removeFinisher(awaitCommit);}};//执行写入存储的操作,后面分析SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);// Okay to notify the listeners before it's hit disk// because the listeners should always get the same// SharedPreferences instance back, which has the// changes reflected in memory.notifyListeners(mcr);
}//看方法名,顾名思义是先提交到内存中
private MemoryCommitResult commitToMemory() {long memoryStateGeneration;boolean keysCleared = false;List<String> keysModified = null;Set<OnSharedPreferenceChangeListener> listeners = null;Map<String, Object> mapToWriteToDisk;//以当前SharedPreferencesImpl对象的mLock为锁synchronized (SharedPreferencesImpl.this.mLock) {// We optimistically don't make a deep copy until// a memory commit comes in when we're already// writing to disk.//是否已经有写操作在执行,是的话复制一份,操作自己复制的这一份if (mDiskWritesInFlight > 0) {// We can't modify our mMap as a currently// in-flight write owns it.  Clone it before// modifying it.// noinspection unchecked//写入前先复制一份,并赋值给mMapmMap = new HashMap<String, Object>(mMap);}mapToWriteToDisk = mMap;//在执行的写操作数+1mDiskWritesInFlight++;boolean hasListeners = mListeners.size() > 0;if (hasListeners) {keysModified = new ArrayList<String>();listeners = new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());}synchronized (mEditorLock) {boolean changesMade = false;if (mClear) {//是清空的话,这里对mapToWriteToDisk执行清空操作if (!mapToWriteToDisk.isEmpty()) {changesMade = true;mapToWriteToDisk.clear();}keysCleared = true;mClear = false;}for (Map.Entry<String, Object> e : mModified.entrySet()) {String k = e.getKey();Object v = e.getValue();// "this" is the magic value for a removal mutation. In addition,// setting a value to "null" for a given key is specified to be// equivalent to calling remove on that key.if (v == this || v == null) {if (!mapToWriteToDisk.containsKey(k)) {continue;}//值为null,清空这个keymapToWriteToDisk.remove(k);} else {if (mapToWriteToDisk.containsKey(k)) {Object existingValue = mapToWriteToDisk.get(k);//前后值相同,不处理if (existingValue != null && existingValue.equals(v)) {continue;}}//更新到内存中mapToWriteToDisk.put(k, v);}changesMade = true;if (hasListeners) {keysModified.add(k);}}//将mModified清空,以备下次使用mModified.clear();if (changesMade) {mCurrentMemoryStateGeneration++;}memoryStateGeneration = mCurrentMemoryStateGeneration;}}//返回一个MemoryCommitResult,包裹结果,包括监听者和写入后的mapreturn new MemoryCommitResult(memoryStateGeneration, keysCleared, keysModified,listeners, mapToWriteToDisk);
}

MemoryCommitResult

private static class MemoryCommitResult {final long memoryStateGeneration;final boolean keysCleared;@Nullable final List<String> keysModified;@Nullable final Set<OnSharedPreferenceChangeListener> listeners;final Map<String, Object> mapToWriteToDisk;final CountDownLatch writtenToDiskLatch = new CountDownLatch(1);@GuardedBy("mWritingToDiskLock")volatile boolean writeToDiskResult = false;boolean wasWritten = false;private MemoryCommitResult(long memoryStateGeneration, boolean keysCleared,@Nullable List<String> keysModified,@Nullable Set<OnSharedPreferenceChangeListener> listeners,Map<String, Object> mapToWriteToDisk) {this.memoryStateGeneration = memoryStateGeneration;this.keysCleared = keysCleared;this.keysModified = keysModified;this.listeners = listeners;this.mapToWriteToDisk = mapToWriteToDisk;}void setDiskWriteResult(boolean wasWritten, boolean result) {this.wasWritten = wasWritten;writeToDiskResult = result;writtenToDiskLatch.countDown();}
}
private void enqueueDiskWrite(final MemoryCommitResult mcr,final Runnable postWriteRunnable) {//isFromSyncCommit是否来自同步的commit,很明显不是,这里postWriteRunnable//不为空final boolean isFromSyncCommit = (postWriteRunnable == null);final Runnable writeToDiskRunnable = new Runnable() {@Overridepublic void run() {synchronized (mWritingToDiskLock) {//写入xml文件writeToFile(mcr, isFromSyncCommit);}synchronized (mLock) {//写操作数减一mDiskWritesInFlight--;}if (postWriteRunnable != null) {postWriteRunnable.run();}}};// Typical #commit() path with fewer allocations, doing a write on// the current thread.if (isFromSyncCommit) {boolean wasEmpty = false;synchronized (mLock) {//如果是来自commit的话,看mDiskWritesInFlight是否是1//为1也就表示没有其他的commit或者apply在执行,只有当前wasEmpty = mDiskWritesInFlight == 1;}if (wasEmpty) {//直接执行writeToDiskRunnablewriteToDiskRunnable.run();return;}}//这里是apply,所以来到这里通过QueuedWork添加这个writeToDiskRunnableQueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}

关于QueuedWork还有一个隐藏的问题,等会在其他里面介绍。

//QueuedWork.java
public static void queue(Runnable work, boolean shouldDelay) {//新建一个HandlerThread,其实就是放在子线程中执行Handler handler = getHandler();synchronized (sLock) {sWork.add(work);if (shouldDelay && sCanDelay) {handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY);} else {handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);}}
}private static Handler getHandler() {synchronized (sLock) {if (sHandler == null) {HandlerThread handlerThread = new HandlerThread("queued-work-looper",Process.THREAD_PRIORITY_FOREGROUND);handlerThread.start();sHandler = new QueuedWorkHandler(handlerThread.getLooper());}return sHandler;}
}

总结:

  1. apply会放在子线程执行写入存储
  2. Editor写入,读取内存,读取存储,等有三把锁保证线程安全。所以SP是线程安全的。

commit

看完了apply的分析,其实commit剩余的已经没啥东西了

@Override
public boolean commit() {long startTime = 0;if (DEBUG) {startTime = System.currentTimeMillis();}//和apply一样先写入内存MemoryCommitResult mcr = commitToMemory();//执行enqueueDiskWrite,注意后面参数传入的是null表示直接执行SharedPreferencesImpl.this.enqueueDiskWrite(mcr, null /* sync write on this thread okay */);try {//阻塞等待执行完成mcr.writtenToDiskLatch.await();} catch (InterruptedException e) {return false;} finally {if (DEBUG) {Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration+ " committed after " + (System.currentTimeMillis() - startTime)+ " ms");}}notifyListeners(mcr);return mcr.writeToDiskResult;
}

总结:
commit和apply都会先写入内存,然后再写入存储,区别是写入存储commit是在当前线程,apply是在子线程执行。

writeToFile

前面分析了apply和commit并没有深入看文件的写入文件这块,继续看下

private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {long startTime = 0;long existsTime = 0;long backupExistsTime = 0;long outputStreamCreateTime = 0;long writeTime = 0;long fsyncTime = 0;long setPermTime = 0;long fstatTime = 0;long deleteTime = 0;if (DEBUG) {startTime = System.currentTimeMillis();}//文件是否存在boolean fileExists = mFile.exists();if (DEBUG) {existsTime = System.currentTimeMillis();// Might not be set, hence init them to a default valuebackupExistsTime = existsTime;}// Rename the current file so it may be used as a backup during the next read//如果文件存在的话将当前文件改名,以备下次读取的时候当备份文件使用if (fileExists) {boolean needsWrite = false;// Only need to write if the disk state is older than this commitif (mDiskStateGeneration < mcr.memoryStateGeneration) {if (isFromSyncCommit) {//来自commit的话直接设置为trueneedsWrite = true;} else {synchronized (mLock) {// No need to persist intermediate states. Just wait for the latest state to// be persisted.if (mCurrentMemoryStateGeneration == mcr.memoryStateGeneration) {needsWrite = true;}}}}if (!needsWrite) {mcr.setDiskWriteResult(false, true);return;}//备份文件是否存在boolean backupFileExists = mBackupFile.exists();if (DEBUG) {backupExistsTime = System.currentTimeMillis();}if (!backupFileExists) {//备份文件不存在,将当前文件改名为备份文件名if (!mFile.renameTo(mBackupFile)) {//重命名失败Log.e(TAG, "Couldn't rename file " + mFile+ " to backup file " + mBackupFile);mcr.setDiskWriteResult(false, false);return;}} else {//存在直接删除当前的文件mFile.delete();}}// Attempt to write the file, delete the backup and return true as atomically as// possible.  If any exception occurs, delete the new file; next time we will restore// from the backup.try {FileOutputStream str = createFileOutputStream(mFile);if (DEBUG) {outputStreamCreateTime = System.currentTimeMillis();}if (str == null) {mcr.setDiskWriteResult(false, false);return;}//通过XmlUtils写入xml文件XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);writeTime = System.currentTimeMillis();FileUtils.sync(str);fsyncTime = System.currentTimeMillis();//关闭流str.close();ContextImpl.setFilePermissionsFromMode(mFile.getPath(), mMode, 0);if (DEBUG) {setPermTime = System.currentTimeMillis();}try {final StructStat stat = Os.stat(mFile.getPath());synchronized (mLock) {mStatTimestamp = stat.st_mtim;mStatSize = stat.st_size;}} catch (ErrnoException e) {// Do nothing}if (DEBUG) {fstatTime = System.currentTimeMillis();}// Writing was successful, delete the backup file if there is one.//写入成功,备份文件已经没有存在的必要了,下次需要的时候读取新的或者直接重命名当前文件mBackupFile.delete();if (DEBUG) {deleteTime = System.currentTimeMillis();}mDiskStateGeneration = mcr.memoryStateGeneration;//设置写入结果mcr.setDiskWriteResult(true, true);...long fsyncDuration = fsyncTime - writeTime;mSyncTimes.add((int) fsyncDuration);mNumSync++;return;} catch (XmlPullParserException e) {Log.w(TAG, "writeToFile: Got exception:", e);} catch (IOException e) {Log.w(TAG, "writeToFile: Got exception:", e);}// Clean up an unsuccessfully written fileif (mFile.exists()) {if (!mFile.delete()) {Log.e(TAG, "Couldn't clean up partially-written file " + mFile);}}mcr.setDiskWriteResult(false, false);
}

其他

SP备份机制

其实看完writeToFile这个方法我们应该已经知道了SP的备份机制,每次读取SP的时候都会创建一个被备份文件,每次写入文件前,先看下这个备份文件是否存在,不存在将当前指向xml的文件改名为备份文件,正常SharedPreferencesImpl构造方法里面已经根据这个xml文件创建了备份文件。
writeToFile方法执行成功会把备份文件删除,这个时候xml文件已经是新的了。异常情况写入文件失败,比如掉电,强制退出等。写入新的SP会失败那么下次loadFromDisk会把备份文件设置为mFile,也就是读取旧的内容,保证不至于失败导致数据被污染。

private void loadFromDisk() {synchronized (mLock) {if (mLoaded) {return;}if (mBackupFile.exists()) {mFile.delete();mBackupFile.renameTo(mFile);}}...}

QueuedWork隐藏问题

刚才说apply还隐藏了一个刺客在哪里呢,在QueuedWork里面,这里参考了字节的文章今日头条 ANR 优化实践系列 - 告别 SharedPreference 等待

public void handlePauseActivity(ActivityClientRecord r, boolean finished, boolean userLeaving,int configChanges, PendingTransactionActions pendingActions, String reason) {...// Make sure any pending writes are now committed.if (r.isPreHoneycomb()) {//Android 11以前的版本,等待waitToFinish执行完成,所以这里会导致Activity一直等待//QueuedWork把任务执行完成,也就是隐藏会导致卡顿或者ANRQueuedWork.waitToFinish();}...
}

存储json

参考请不要滥用SharedPreference

还有一些童鞋,他在sp里面存json或者HTML;这么做不是不可以,但是,如果这个json相对较大,那么也会引起sp读取速度的急剧下降。
JSON或者HTML格式存放在sp里面的时候,需要转义,这样会带来很多&这种特殊符号,sp在解析碰到这个特殊符号的时候会进行特殊的处理,引发额外的字符串拼接以及函数调用开销。而JSON本来就是可以用来做配置文件的,你干嘛又把它放在sp里面呢?多此一举。下面我写个demo验证一下。

参考链接

https://juejin.cn/post/6961961476047568932?searchId=20240504152606909D2A140930E1DACD5E
https://juejin.cn/post/6884505736836022280?searchId=20240503211729C6AE6135B0EDD86DCF0C
https://weishu.me/2016/10/13/sharedpreference-advices/


http://www.ppmy.cn/server/32891.html

相关文章

【Linux】awk命令学习

最近用的比较多&#xff0c;学习总结一下。 文档地址&#xff1a;https://www.gnu.org/software/gawk/manual/gawk.html 一、awk介绍二、语句结构1.条件控制语句1&#xff09;if2&#xff09;for3&#xff09;while4&#xff09;break&continue&next&exit 2.比较运…

OpenCV(六) —— Android 下的人脸识别

本篇我们来介绍在 Android 下如何实现人脸识别。 上一篇我们介绍了如何在 Windows 下通过 OpenCV 实现人脸识别&#xff0c;实际上&#xff0c;在 Android 下的实现的核心原理是非常相似的&#xff0c;因为 OpenCV 部分的代码改动不大&#xff0c;绝大部分代码可以直接移植到 …

快速了解Django:核心概念解析与实践指南

title: 快速了解Django&#xff1a;核心概念解析与实践指南 date: 2024/5/1 20:31:41 updated: 2024/5/1 20:31:41 categories: 后端开发 tags: Django核心路由系统视图系统ORM管理中间件Web框架登录装饰器 第一章&#xff1a;Django简介 背景和发展历程&#xff1a; Djan…

微服务---gateway网关

我们现在知道了通过nacos注册服务&#xff0c;通过feign实现服务间接口的调用&#xff0c;那对于不同权限的用户访问同一个接口&#xff0c;我们怎么知道他是否具有访问的权限呢&#xff1f;或者我们怎么判断是否用户已经登录了呢&#xff1f;这些都可以通过gateway进行实现~ …

5G Advanced and Release18简述

5G Advanced 5G-Advanced, formally defined in 3GPP Release 18, represents an upgrade to existing 5G networks. 先睹robot总结的5G Advanced的advancements: Enhanced Mobility and Reliability: 5G-Advanced will support advanced applications with improved mobility…

将VM虚拟机Ubuntu20.04系统扩容

一、拓展虚拟机硬盘空间 随着学习的深入&#xff0c;虚拟机里面的内容越来越多&#xff0c;我们可能会面临着硬盘空间不足的问题。今天我们就来沉浸式体验一把给虚拟机扩容。 二、拓展VM虚拟机硬盘前须知 在硬盘拓展时需要注意的一点是有快照的话拓展不了说是&#xff0c;先删除…

Stable Diffusion webUI 配置指南

Stable Diffusion webUI 配置指南 本博客主要介绍部署Stable Diffusion到本地&#xff0c;生成想要的风格图片。 文章目录 Stable Diffusion webUI 配置指南1、配置环境&#xff08;1&#xff09;pip环境[可选]&#xff08;2&#xff09;conda环境[可选] 2、配置Stable Diffu…

Qt中的对象树

一. QT对象树的概念 QObject 的构造函数中会传入一个 Parent 父对象指针&#xff0c;children() 函数返回 QObjectList。即每一个 QObject 对象有且仅有一个父对象&#xff0c;但可以有很多个子对象。 那么Qt这样设计的好处是什么呢&#xff1f;很简单&#xff0c;就是为了方…