当前位置:网站首页>SharedPreferences 源码分析
SharedPreferences 源码分析
2022-07-06 04:15:00 【jthou20121212】
SharedPreferences 是系统提供的一个适合用于存储少量键值对数据的持久化存储方案,特点是 API 比较简单使用比较方便,但它也有一些缺陷如果使用不当可能会导致卡顿甚至是 ANR 并且 SharedPreferences 不支持多进程
先看一下 SharedPreferences 的获取过程
private static ArrayMap<String, ArrayMap<File, SharedPreferencesImpl>> sSharedPrefsCache;
private ArrayMap<String, File> mSharedPrefsPaths;
// android.app.ContextImpl#getSharedPreferences(java.lang.String, int)
// SharedPreferences 会根据传入的 name 生成一个对应的 xml 文件
// 根据键值对存储的数据就在这个 xml 文件里
public SharedPreferences getSharedPreferences(String name, int mode) {
// 代码省略 ..
File file;
synchronized (ContextImpl.class) {
if (mSharedPrefsPaths == null) {
mSharedPrefsPaths = new ArrayMap<>();
}
// name 与 file 的映射
file = mSharedPrefsPaths.get(name);
if (file == null) {
// 根据名字创建一个文件,文件名就是传入的 name
file = getSharedPreferencesPath(name);
mSharedPrefsPaths.put(name, file);
}
}
return getSharedPreferences(file, mode);
}
public SharedPreferences getSharedPreferences(File file, int mode) {
SharedPreferencesImpl sp;
synchronized (ContextImpl.class) {
// 看缓存中有没有
final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
sp = cache.get(file);
if (sp == null) {
// 代码省略 ..
sp = new SharedPreferencesImpl(file, mode);
cache.put(file, sp);
return sp;
}
}
// 如果使用了 MODE_MULTI_PROCESS 标记在每一次获取 SharedPreferences 实例时都会重新读一下文件
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();
// 包名与 App 内所有文件和 SharedPreferences 接口实例的映射
ArrayMap<File, SharedPreferencesImpl> packagePrefs = sSharedPrefsCache.get(packageName);
if (packagePrefs == null) {
packagePrefs = new ArrayMap<>();
sSharedPrefsCache.put(packageName, packagePrefs);
}
return packagePrefs;
}
SharedPreferences 是一个接口我们拿到的是它的实现类 SharedPreferencesImpl 流程也比较简单先通过传入的 name 获取文件再通过文件和传入的对文件的操作模式创建一个 SharedPreferencesImpl 并且缓存起来,在 SharedPreferencesImpl 的构造方法里会读文件里的数据映射到一个 map 里,而且 sSharedPrefsCache 是静态的也没有提供释放的方法所以 App 内所有的数据读取以后都会一直在内存里
SharedPreferencesImpl(File file, int mode) {
// 存储数据的源文件
mFile = file;
// 备份文件
mBackupFile = makeBackupFile(file);
mMode = mode;
// 读文件操作是否完成的标记
mLoaded = false;
mMap = null;
mThrowable = null;
// 开启一个线程去读取文件
startLoadFromDisk();
}
private void startLoadFromDisk() {
// 标记位表示是否加载完成
// 使用的锁是 mLock
synchronized (mLock) {
// 先设置为 false
mLoaded = false;
}
new Thread("SharedPreferencesImpl-load") {
public void run() {
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 {
str = new BufferedInputStream(
new FileInputStream(mFile), 16 * 1024);
// 读文件将数据保存到 map 里根据 key 取数据时都是从这个 map 中去取而不是每次都读文件
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 (thrown == null) {
if (map != null) {
mMap = map;
// 文件更新时间
mStatTimestamp = stat.st_mtim;
// 文件大小
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 {
// 通知所有在等待的线程读文件结束
mLock.notifyAll();
}
}
}
在创建 SharedPreferences 对象时会开启一个线程读取文件,内容会保存到一个 map 里之后根据 key 去取数据都是从这个 map 中取而不是每次都去读文件
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 {
// 如果读文件还没有结束则等待完成
mLock.wait();
} catch (InterruptedException unused) {
}
}
if (mThrowable != null) {
throw new IllegalStateException(mThrowable);
}
}
SharedPreferences 对象在创建时会开启一个子线程去读取文件数据到 map 里,读文件是一个耗时操作,如果在读文件过程还没有结束主线程就去取数据,主线程会阻塞等待读文件线程结束。所以一个小技巧可以节省一下等待时间,既然读操作会等读文件过程结束并且读文件是在另一个线程上执行的,所以可以先去读文件再去做一些别的事情再去取数据
// 先让sp去另外一个线程加载 SharedPreferences sp = getSharedPreferences("test", MODE_PRIVATE); // 做一堆别的事情 setContentView(testSpJson); // ... // OK,这时候估计已经加载完了吧,就算没完,我们在原本应该等待的时间也做了一些事! String testValue = sp.getString("testKey", null);
此部分代码来自 请不要滥用SharedPreference
读操作是直接读取的 map 写操作相对读操作要复杂一些,需要先通过 edit 方法获取 Editor 对象,它也是一个接口具体实现类是 EditorImpl 每次调用 edit 方法都会创建一个 EditorImpl 对象,多次写操作会先将操作记录下来调用 commit/apply 方法后再一次提交,所以应尽量多次写操作后再 commit/apply 避免每写操作一次就 commit/apply 所以 SPUtils 这种工具类比较简单但其实是不推荐的
public final class EditorImpl implements Editor {
// 对数据的操作会保存到这里
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) {
// 移除操作比较特殊会把 value 设置为自己在写入磁盘时会判断
mModified.put(key, this);
return this;
}
}
@Override
public Editor clear() {
synchronized (mEditorLock) {
// 标记调用了 clear 方法
mClear = true;
return this;
}
}
}
对于 putXxx 写操作都是操作的 mModified 对于 remove 操作是把 value 设置为 this 对于 clear 操作是设置标记位 mClear 为 true 然后调用 commit/apply 方法将改动写入文件,区别在于 commit 是同步的 apply 是异步的
public boolean commit() {
// 将改动操作提交到内存
MemoryCommitResult mcr = commitToMemory();
// 将内存的改动写入磁盘
SharedPreferencesImpl.this.enqueueDiskWrite(
mcr, null /* sync write on this thread okay */);
try {
// 等待磁盘写入完成
mcr.writtenToDiskLatch.await();
} catch (InterruptedException e) {
return false;
}
// 回调监听
notifyListeners(mcr);
// 返回写入结果
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 (mDiskWritesInFlight > 0) {
mMap = new HashMap<String, Object>(mMap);
}
mapToWriteToDisk = mMap;
// 执行写操作的线程数
mDiskWritesInFlight++;
boolean hasListeners = mListeners.size() > 0;
if (hasListeners) {
keysModified = new ArrayList<String>();
listeners = new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());
}
synchronized (mEditorLock) {
boolean changesMade = false;
// 如果调用了 clear 方法
if (mClear) {
// 数据不为空清空数据
if (!mapToWriteToDisk.isEmpty()) {
// 表示数据有改变
changesMade = true;
mapToWriteToDisk.clear();
}
keysCleared = true;
// 重置 mClear 标记
mClear = false;
}
// 遍历 mModified 判断是否有修改
for (Map.Entry<String, Object> e : mModified.entrySet()) {
String k = e.getKey();
Object v = e.getValue();
// 如果 value 是 this 说明是 remove 操作
// 如果将 value 是 null 也会执行 remove 操作
// 所以如果调用 putXxx 将某个 key 对应的 value 设置为 null 这个 key 也会被移除
if (v == this || v == null) {
if (!mapToWriteToDisk.containsKey(k)) {
continue;
}
mapToWriteToDisk.remove(k);
} else {
if (mapToWriteToDisk.containsKey(k)) {
Object existingValue = mapToWriteToDisk.get(k);
// 新的值与旧的值不一样才会替换
if (existingValue != null && existingValue.equals(v)) {
continue;
}
}
mapToWriteToDisk.put(k, v);
}
// mModified 不为空就认为数据有变化
changesMade = true;
if (hasListeners) {
keysModified.add(k);
}
}
// 清空 mModified
mModified.clear();
// 如果数据有变化在内存中的数据版本号加一
if (changesMade) {
mCurrentMemoryStateGeneration++;
}
memoryStateGeneration = mCurrentMemoryStateGeneration;
}
}
// 创建一个 MemoryCommitResult 对象它是将数据改动写入内存的结果
// memoryStateGeneration 表示当前内存数据的版本号
// keysCleared 表示是否执行了清空操作
// keysModified 是所有 value 有变化的 key
// listeners 是监听
// mapToWriteToDisk 是一系列更改操作后内存中的数据
// 在 MemoryCommitResult 的构造方法中会初始化一个初始值为 1 的 CountDownLatch 对象
return new MemoryCommitResult(memoryStateGeneration, keysCleared, keysModified,
listeners, mapToWriteToDisk);
}
// commit/apply 都会调用此方法
// commit 调用时 postWriteRunnable 为 null
// apply 调用时 postWriteRunnable 不为空
private void enqueueDiskWrite(final MemoryCommitResult mcr,
final Runnable postWriteRunnable) {
// 表示来自 commit 还是 apply 的调用
final boolean isFromSyncCommit = (postWriteRunnable == null);
// 将内存中的数据写入文件
final Runnable writeToDiskRunnable = new Runnable() {
@Override
public void run() {
synchronized (mWritingToDiskLock) {
// 将内存中的数据写入磁盘
writeToFile(mcr, isFromSyncCommit);
}
synchronized (mLock) {
// 写入操作完成操作线程数减一
mDiskWritesInFlight--;
}
// 如果 postWriteRunnable 不为 null 执行 postWriteRunnable
// 从 commit 中调用 postWriteRunnable 为 null
// 从 apply 中调用 postWriteRunnable 不为 null
if (postWriteRunnable != null) {
postWriteRunnable.run();
}
}
};
// 如果是来自 commmit 的调用
if (isFromSyncCommit) {
boolean wasEmpty = false;
synchronized (mLock) {
// 如果只有当前一个线程在执行写操作
wasEmpty = mDiskWritesInFlight == 1;
}
// 则直接在当前线程执行写入文件
if (wasEmpty) {
writeToDiskRunnable.run();
return;
}
}
// 如果是在当前线程执行写文件之后就结束了
// 如果是 apply 方法或者是 commit 方法但是有多个线程在写文件则加入到队列去执行
QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}
public static void queue(Runnable work, boolean shouldDelay) {
// QueuedWorkHandler 对象,它的 looper 是在 HandlerThread 的 run 中创建的,所以发送给它的消息会在这个线程执行
Handler handler = getHandler();
synchronized (sLock) {
// 加入到队列
sWork.add(work);
// commit 进入不需要延时 apply 进入需要延时
if (shouldDelay && sCanDelay) {
handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY);
} else {
// 发送消息给 handler
handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);
}
}
}
// QueuedWorkHandler 收到 MSG_RUN 消息之后会立即执行 processPendingWork 方法
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) {
// 依次执行所有 runnable
for (Runnable w : work) {
w.run();
}
}
}
}
在 processPendingWork 方法中就会依次执行队列中的 runnable 也就是上面传入的 writeToDiskRunnable 在这个方法里会执行真正的写入磁盘的操作 writeToFile
private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
// 代码省略 ..
boolean fileExists = mFile.exists();
// 源文件存在
if (fileExists) {
boolean needsWrite = false;
// 比较磁盘数据版本与内存数据版本只有磁盘数据版本比内存数据版本低时才需要写入
if (mDiskStateGeneration < mcr.memoryStateGeneration) {
// 如果是 commit 则需要写入
if (isFromSyncCommit) {
needsWrite = true;
} else {
synchronized (mLock) {
// 如果是 apply 比较当前内存数据版本和要写入文件的内存数据版本一致就需要写入
// 不一致说明又有了新的数据改动等待下一次写入
if (mCurrentMemoryStateGeneration == mcr.memoryStateGeneration) {
needsWrite = true;
}
}
}
}
if (!needsWrite) {
// 只有当数据真正有变化时才写入避免无意义的写入
mcr.setDiskWriteResult(false, true);
return;
}
boolean backupFileExists = mBackupFile.exists();
// 如果备份文件不存在
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();
}
}
try {
// 省略将内存中的数据写入源文件代码 ..
// 写入文件成功删除备份文件
mBackupFile.delete();
if (DEBUG) {
deleteTime = System.currentTimeMillis();
}
// 记录磁盘数据版本号
mDiskStateGeneration = mcr.memoryStateGeneration;
// 写入成功
mcr.setDiskWriteResult(true, true);
// 代码省略 ..
return;
} catch (XmlPullParserException e) {
Log.w(TAG, "writeToFile: Got exception:", e);
} catch (IOException e) {
Log.w(TAG, "writeToFile: Got exception:", e);
}
// 写入成功在上面就返回了写入失败删除源文件
// 如果发生异常在下一次构造 SharedPreferencesImpl 对象时会将备份文件重命名为源文件
if (mFile.exists()) {
if (!mFile.delete()) {
Log.e(TAG, "Couldn't clean up partially-written file " + mFile);
}
}
mcr.setDiskWriteResult(false, false);
}
不管写入成功还是失败最后都会调用 setDiskWriteResult 方法
void setDiskWriteResult(boolean wasWritten, boolean result) {
this.wasWritten = wasWritten;
writeToDiskResult = result;
// 释放因调用 writtenToDiskLatch.await 而阻塞等待的线程
writtenToDiskLatch.countDown();
}
调用 setDiskWriteResult 后因为调用 commit 而阻塞的线程就释放了,下面来看一下 apply
public void apply() {
// commit 中已经分析了
final MemoryCommitResult mcr = commitToMemory();
final Runnable awaitCommit = new Runnable() {
@Override
public void run() {
try {
mcr.writtenToDiskLatch.await();
} catch (InterruptedException ignored) {
}
}
};
// 将 awaitCommit 加入队列 awaitCommit 中就做了一件事等待文件写入完成
QueuedWork.addFinisher(awaitCommit);
Runnable postWriteRunnable = new Runnable() {
@Override
public void run() {
// 执行 awaitCommit 等待文件写入完成
awaitCommit.run();
// 移除 awaitCommit
QueuedWork.removeFinisher(awaitCommit);
}
};
// 与 commit 一样调用 enqueueDiskWrite 只不过这里传入的第二个参数不为空
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
// 写入磁盘前回调监听
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();
}
}
};
// 代码省略 ..
// 加入到队列在 HandlerThread 里依次执行
// writeToDiskRunnable 中写入磁盘完成后紧接着就会执行 postWriteRunnable
// 而 postWriteRunnable 只是等待磁盘写入完成现在已经完成了所以直接就执行完成从队列中移除了
QueuedWork.queue(writeToDiskRunnable, true);
}
在 Activity 、service 这些组件的生命周期方法中会调用 android.app.QueuedWork#waitToFinish 等待所有写入磁盘操作完成,如果有多个写入操作或者文件比较大等待的时间就会比较长严重情况可能就会引发 ANR
public static void waitToFinish() {
// 代码省略 ..
try {
while (true) {
Runnable finisher;
// 依次取出每一个任务去执行
synchronized (sLock) {
finisher = sFinishers.poll();
}
// 所有任务都执行完成退出
if (finisher == null) {
break;
}
// 每一个任务都是等待写磁盘操作完成所以都执行完成就是所有写磁盘操作完成
finisher.run();
}
} finally {
sCanDelay = true;
}
// 代码省略 ..
}
对于数据改动先通过 edit 方法拿到一个 EditorImpl 对象,改动操作是记录在一个 map 里,清空是使用了一个 boolean 标记,在调用 commit/apply 之后判断是否调用了清空方法是的话清空数据再遍历 map 将所有有变化的数据记录到内存里,现在内存里就是最新的数据了。对于 commit 方法会判断是否只有一个线程在改动文件如果只有一个线程则在当前线程执行磁盘写入操作,如果有多个线程在改动文件则在 HandlerThread 中执行磁盘写入操作,不管是在哪个线程执行磁盘写入都会等待磁盘写入完成所以是同步写入,因为它是在 commit 方法中等待磁盘写入完成。对于 apply 方法在一个叫做 awaitCommit 的 Runnable 中等待磁盘写入完成,并且在它执行前会加入到一个队列中,执行完成后再移除,而且这个 awaitCommit 的执行和移除又是在另一个叫做 postWriteRunnable 的 Runnable 中执行的,这个 postWriteRunnable 又是在另一个叫做 writeToDiskRunnable 中的 Runnable 中执行的, writeToDiskRunnable 是在 HandlerThread 中执行的,它会等待磁盘写入完成之后执行 postWriteRunnable 又会执行 awaitCommit 而 awaitCommit 只做了一件事就是在磁盘写入完成前阻塞住所以在 apply 方法的执行逻辑中磁盘写入完成后几个 runnable 也就都执行完成了,而在 Activity Service 一些组件的生命周期方法中会等待 awaitCommit 所加入的队列等待所有磁盘写入操作完成,所以虽然 apply 是异步操作但也可能做成 ANR,简单来说就是 commit 是在当前线程等待写操作完成 apply 是在异步线程等待写操作完成并且 apply 会把等待写操作完成的 runnable 加入到一个队列里,在 Activity、Service 组件的生命周期方法里会依次执行这些加入到队列里的等待写操作完成的 ruannble 执行完成。最后总结几点:
- 每一次读/写操作都是全量的并且数据会一直在内存里不会释放
- 不支持多进程 MODE_MULTI_PROCESS 的作用是在每一次获取 SharedPreferences 对象实例时判断文件有改动重新读取一次数据到内存
- 在第一次获取 SharedPreferences 对象时会读取文件内容到内存如果在这个动作完成前就去读数据会阻塞等待文件读取完成可能造成卡顿
- 在 Activity Service 一些组件的生命周期方法里会等待所有写磁盘操作完成可能会造成 ANR
- 通过 registerOnSharedPreferenceChangeListener 方法注册的监听对于 commit 方法会在磁盘写入完成后回调而 apply 方法会在数据写入内存后回调并且都是在主线程回调
- 通过在每次写入文件之前备份源文件写入成功删除备份文件写入失败删除源文件并且将备份文件命名为源文件的方式保证即使文件损坏数据丢失也只是丢失最后一次保存的数据
参考与感谢
剖析 SharedPreference apply 引起的 ANR 问题
Android 之不要滥用 SharedPreferences(上)
Android 之不要滥用 SharedPreferences(下)
一文读懂 SharedPreferences 的缺陷及一点点思考
边栏推荐
- [FPGA tutorial case 12] design and implementation of complex multiplier based on vivado core
- [PSO] Based on PSO particle swarm optimization, matlab simulation of the calculation of the lowest transportation cost of goods at material points, including transportation costs, agent conversion cos
- Yyds dry goods inventory hcie security Day11: preliminary study of firewall dual machine hot standby and vgmp concepts
- Lambda expression learning
- 使用JS完成一个LRU缓存
- 解决“C2001:常量中有换行符“编译问题
- DM8 archive log file manual switching
- /usr/bin/gzip: 1: ELF: not found/usr/bin/gzip: 3: : not found/usr/bin/gzip: 4: Syntax error:
- Query the number and size of records in each table in MySQL database
- Security xxE vulnerability recurrence (XXe Lab)
猜你喜欢
【FPGA教程案例11】基于vivado核的除法器设计与实现
User datagram protocol UDP
Basic knowledge of binary tree, BFC, DFS
When debugging after pycharm remote server is connected, trying to add breakpoint to file that does not exist: /data appears_ sda/d:/segmentation
Solution to the problem that the root account of MySQL database cannot be logged in remotely
Interface idempotency
Yyds dry goods inventory hcie security Day11: preliminary study of firewall dual machine hot standby and vgmp concepts
MySql数据库root账户无法远程登陆解决办法
【PSO】基于PSO粒子群优化的物料点货物运输成本最低值计算matlab仿真,包括运输费用、代理人转换费用、运输方式转化费用和时间惩罚费用
Lora gateway Ethernet transmission
随机推荐
Solution of storage bar code management system in food industry
Web components series (VII) -- life cycle of custom components
Global and Chinese market of plasma separator 2022-2028: Research Report on technology, participants, trends, market size and share
POI add border
1291_ Add timestamp function in xshell log
【FPGA教程案例11】基于vivado核的除法器设计与实现
Fedora/REHL 安装 semanage
In Net 6 CS more concise method
软考 系统架构设计师 简明教程 | 总目录
One question per day (Mathematics)
Determine which week of the month the day is
E. Best Pair
DM8 archive log file manual switching
Slow SQL fetching and analysis of MySQL database
Lambda expression learning
During pycharm debugging, the view is read only and pause the process to use the command line appear on the console input
Python book learning notes - Chapter 09 section 01 create and use classes
VPP性能测试
STC8H开发(十二): I2C驱动AT24C08,AT24C32系列EEPROM存储
Ipv4中的A 、B、C类网络及子网掩码