[转载]Android数据持久化之SharedPreferences

一、抛出问题

SharedPreferences作为Android中数据存储方式的一种,我们经常会用到,它适合用来保存那些少量的数据,特别是键值对数据,比如配置信息,登录信息等。不过要想做到正确使用SharedPreferences,就需要弄清楚下面几个问题:

(1) 每次调用getSharedPreferences时都会创建一个SharedPreferences对象吗?这个对象具体是哪个类对象?
(2) 在UI线程中调用getXXX有可能导致ANR吗?
(3) 为什么SharedPreferences只适合用来存放少量数据,为什么不能把SharedPreferences对应的xml文件当成普通文件一样存放大量数据?
(4) commit和apply有什么区别?
(5) SharedPreferences每次写入时是增量写入吗?
(6) SharedPreferences 是如何初始化的,它会阻塞线程嘛?如果会,是什么原因。而且每次获取 SP 对象真的会很慢吗?
(7) SharedPreferences支持进程同步吗?怎么让它支持

二、源码分析

要想弄清楚上面几个问题,需要查看SharedPreferences的源码实现才能解决。先从Context的getSharedPreferences开始:

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

我们知道Android中的Context类体系其实是使用了装饰者模式,而被装饰对象就这个mBase,它其实就是一个ContextImpl对象,ContextImpl的getSharedPreferences方法:

	@Override
    public SharedPreferences getSharedPreferences(String name, int mode) {
        SharedPreferencesImpl sp;
        synchronized (ContextImpl.class) {
        	//1.全局使用静态变量(会存在手机启动后直至关机),在内存中来存储所有进程已经读进来的sp文件;
        	//因此sp不适合存在大对象的文件,会增加内存的消耗,甚至上期无用占用
            if (sSharedPrefs == null) {
                sSharedPrefs = new ArrayMap<String, ArrayMap<String, SharedPreferencesImpl>>();
            }
			//2. 根据包名,获取该进程的packagePrefs;
			//如果没有,则创建并将该对象放到全局sSharedPrefs中
            final String packageName = getPackageName();
            ArrayMap<String, SharedPreferencesImpl> packagePrefs = sSharedPrefs.get(packageName);
            if (packagePrefs == null) {
                packagePrefs = new ArrayMap<String, SharedPreferencesImpl>();
                sSharedPrefs.put(packageName, packagePrefs);
            }

            //这里处理name为null的情况
            // we would stringify it to "null.xml".  Nice.
            if (mPackageInfo.getApplicationInfo().targetSdkVersion <
                    Build.VERSION_CODES.KITKAT) {
                if (name == null) {
                    name = "null";
                }
            }
			//3. 根据文件名name获取SharedPreferencesImpl对象
			//如果还没有创建,则先创建该文件
            sp = packagePrefs.get(name);
            if (sp == null) {
                File prefsFile = getSharedPrefsFile(name);
                sp = new SharedPreferencesImpl(prefsFile, mode);
                packagePrefs.put(name, sp);
                return sp;
            }
        }
        
        if ((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;
    }

可以看到这里使用到了单例模式,sSharedPrefs 是一个ArrayMap,packagePrefs也是一个ArrayMap,它们的关系是这样的:
packagePrefs存放文件name与SharedPreferencesImpl键值对,sSharedPrefs存放包名与ArrayMap键值对。注意sSharedPrefs是static变量,也就是一个类只有一个实例,因此你每次getSharedPreferences其实拿到的都是同一个SharedPreferences对象。这里回答第一个问题,对于一个相同的SharedPreferences name,获取到的都是同一个SharedPreferences对象,它其实是SharedPreferencesImpl对象。

SharedPreferencesImpl构造方法:


    SharedPreferencesImpl(File file, int mode) {
        mFile = file;
        mBackupFile = makeBackupFile(file);//对当前文件的备份
        mMode = mode;
        mLoaded = false;//到这里,文件还没有从磁盘上加载或者写入磁盘
        mMap = null;
        //立马启动新线程去磁盘读取文件
        startLoadFromDisk();
    }

与mBackupFile有关的等后面说,看startLoadFromDisk方法:


    private void startLoadFromDisk() {
        synchronized (mLock) {
            mLoaded = false;
        }
        new Thread("SharedPreferencesImpl-load") {
            public void run() {
                loadFromDisk();
            }
        }.start();
    }

    private void loadFromDisk() {
    	//之前版本是对SharedPreferencesImpl进行上锁,
    	//缺点:多个sp文件的时候,在高并发时明显降低性能。
    	//这里:以mLock上锁,降低锁的粒度,在高并发的时候不会有性能明显消耗,同时保证对sp文件的并发操作安全
        synchronized (mLock) {
        	//如果已经加载完成,则返回
            if (mLoaded) {
                return;
            }
            //如果备份文件存在,则原文件需要先删除,将备份文件命名为原文件
            if (mBackupFile.exists()) {
                mFile.delete();
                mBackupFile.renameTo(mFile);
            }
        }

        // Debugging
        if (mFile.exists() && !mFile.canRead()) {
            Log.w(TAG, "Attempt to read preferences file " + mFile + " without permission");
        }

        Map map = null;
        StructStat stat = null;
        try {
            stat = Os.stat(mFile.getPath());
            if (mFile.canRead()) {
                BufferedInputStream str = null;
                try {
                    str = new BufferedInputStream(
                            new FileInputStream(mFile), 16*1024);
                    map = XmlUtils.readMapXml(str);
                } catch (Exception e) {
                    Log.w(TAG, "Cannot read " + mFile.getAbsolutePath(), e);
                } finally {
                    IoUtils.closeQuietly(str);
                }
            }
        } catch (ErrnoException e) {
            /* ignore */
        }
		//3. 到这里已经完成了文件的读取,都在map中
        synchronized (mLock) {
            mLoaded = true;
            if (map != null) {
                mMap = map;
                mStatTimestamp = stat.st_mtim;
                mStatSize = stat.st_size;
            } else {
                mMap = new HashMap<>();
            }
            mLock.notifyAll();
        }
    }

可以看到对于一个SharedPreferences文件name,第一次调用getSharedPreferences时会去创建一个SharedPreferencesImpl对象,它会开启一个子线程,然后去把指定的SharedPreferences文件中的键值对全部读取出来,存放在一个Map中。如果我们在UI线程中这样子写:

SharedPreferences sp = getSharedPreferences("test", Context.MODE_PRIVATE);
String name = sp.getString("name", null);

调用getString时那个SharedPreferencesImpl构造方法开启的子线程可能还没执行完(比如文件比较大时全部读取会比较久),这时getString当然还不能获取到相应的值,必须阻塞到那个子线程读取完为止,getString方法:

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

    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) {
            }
        }
    }

    public Map<String, ?> getAll() {
        synchronized (mLock) {
            awaitLoadedLocked();
            //noinspection unchecked
            return new HashMap<String, Object>(mMap);
        }
    }

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

显然这个awaitLoadedLocked方法就是用来等mLock这个锁的,在loadFromDiskLocked方法的最后我们也可以看到它调用了notifyAll方法,这时如果getString之前阻塞了就会被唤醒。那么现在这里有一个问题,我们的getString是写在UI线程中,如果那个getString被阻塞太久了,比如60s,这时就会出现ANR,因此要根据具体情况考虑是否需要把SharedPreferences的读写放在子线程中。这里回答第二个问题,在UI线程中调用getXXX可能会导致ANR。同时可以回答第三个问题,SharedPreferences只能用来存放少量数据,如果一个SharedPreferences对应的xml文件很大的话,在初始化时会把这个文件的所有数据都加载到内存中,这样就会占用大量的内存,有时我们只是想读取某个xml文件中一个key的value,结果它把整个文件都加载进来了,显然如果必要的话这里需要进行相关优化处理。

SharedPreferences的getXXX的实现基本都是一样,这里就不逐个分析了。

SharedPreferences的初始化和读取比较简单,写操作就相对复杂了点,我们知道写一个SharedPreferences文件都是先要调用edit方法获取到一个Editor对象:


    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) {
            awaitLoadedLocked();
        }

        return new EditorImpl();
    }

其实拿到的是一个EditorImpl对象,它是SharedPreferencesImpl的内部类。

这里可以看到一个编码的习惯:SharedPreferences是一个接口类,Editor是SharedPreferences的内部接口类,也就是接口定义,根据功能的依赖程度可以设计为内部接口类。然后在实现类SharedPreferencesImpl中实现SharedPreferences接口,同时EditorImpl也是SharedPreferencesImpl的内部类,这样就是同一功能的高内聚。而对外的可以直接有SharedPreferences接口类和Editor接口类进行操作,而具体的实现可以不用关心。

public final class EditorImpl implements Editor {
        private final Map<String, Object> mModified = Maps.newHashMap();
        private boolean mClear = false;
        ......
    }

可以看到它有一个Map对象mModified,用来保存“脏数据”,也就是你每次put的时候其实是把那个键值对放到这个mModified 中,最后调用apply或者commit才会真正把数据写入文件中,比如看putString:

public Editor putString(String key, String value) {
            synchronized (mLock) {
                mModified.put(key, value);
                return this;
            }
        }

其它putXXX代码基本也是一样的。EditorImpl类的关键就是apply和commit,不过它们有一些区别,先看commit方法:

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;
        }

关键有两步,先调用commitToMemory,再调用enqueueDiskWrite,commitToMemory就是产生一个“合适”的MemoryCommitResult对象mcr,然后调用enqueueDiskWrite时需要把这个对象传进去,commitToMemory方法:


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

            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
                    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 (mLock) {
                    boolean changesMade = false;

                    if (mClear) {
                        if (!mMap.isEmpty()) {
                            changesMade = true;
                            mMap.clear();
                        }
                        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 (!mMap.containsKey(k)) {
                                continue;
                            }
                            mMap.remove(k);
                        } else {
                            if (mMap.containsKey(k)) {
                                Object existingValue = mMap.get(k);
                                if (existingValue != null && existingValue.equals(v)) {
                                    continue;
                                }
                            }
                            mMap.put(k, v);
                        }

                        changesMade = true;
                        if (hasListeners) {
                            keysModified.add(k);
                        }
                    }

                    mModified.clear();

                    if (changesMade) {
                        mCurrentMemoryStateGeneration++;
                    }

                    memoryStateGeneration = mCurrentMemoryStateGeneration;
                }
            }
            return new MemoryCommitResult(memoryStateGeneration, keysModified, listeners,
                    mapToWriteToDisk);
        }

这里需要弄清楚两个对象mMapmModified,mMap是存放当前SharedPreferences文件中的键值对,而mModified是存放此时edit时put进去的键值对mDiskWritesInFlight表示正在等待写的操作数量。可以看到这个方法中首先处理了clear标志,它调用的是mMap.clear(),然后再遍历mModified将新的键值对put进mMap,也就是说在一次commit事务中,如果同时put一些键值对和调用clear,那么clear掉的只是之前的键值对,这次put进去的键值对还是会被写入的。遍历mModified时,需要处理一个特殊情况,就是如果一个键值对的value是this(SharedPreferencesImpl)或者是null那么表示将此键值对删除,这个在remove方法中可以看到:

 public Editor remove(String key) {
            synchronized (mLock) {
                mModified.put(key, this);
                return this;
            }
        }

commit接下来就是调用enqueueDiskWrite方法:

 private void enqueueDiskWrite(final MemoryCommitResult mcr,
                                  final Runnable postWriteRunnable) {
        //判断postWriteRunnable是否为null,调用commit时它为null,而调用apply时它不为null。
        final boolean isFromSyncCommit = (postWriteRunnable == null);

        final Runnable writeToDiskRunnable = new Runnable() {
                public void run() {
                    synchronized (mWritingToDiskLock) {
                        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) {
                wasEmpty = mDiskWritesInFlight == 1;
            }
            if (wasEmpty) {
                writeToDiskRunnable.run();
                return;
            }
        }

        QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
    }

先定义一个Runnable,注意实现Runnable与继承Thread的区别,Runnable表示一个任务,不一定要在子线程中执行,一般优先考虑使用Runnable。这个Runnable中先调用writeToFile进行写操作,写操作需要先获得mWritingToDiskLock,也就是写锁。然后执行mDiskWritesInFlight–,表示正在等待写的操作减少1。最后判断postWriteRunnable是否为null,调用commit时它为null,而调用apply时它不为null。

Runnable定义完,就判断这次是commit还是apply,如果是commit,即isFromSyncCommit为true,而且有1个写操作需要执行,那么就调用writeToDiskRunnable.run(),注意这个调用是在当前线程中进行的。如果不是commit,那就是apply,这时调用QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable),这个QueuedWork类其实很简单,里面有一个SingleThreadExecutor,用于异步执行这个writeToDiskRunnable。
这里就可以回答第四个问题了,commit的写操作是在调用线程中执行的,而apply内部是用一个单线程的线程池实现的,因此写操作是在子线程中执行的。

说一下那个mBackupFile,SharedPreferences在写入时会先把之前的xml文件改成名成一个备份文件,然后再将要写入的数据写到一个新的文件中,如果这个过程执行成功的话,就会把备份文件删除。由此可见每次即使只是添加一个键值对,也会重新写入整个文件的数据,这也说明SharedPreferences只适合保存少量数据,文件太大会有性能问题。这里回答第五个问题,SharedPreferences每次写入都是整个文件重新写入,不是增量写入。

三、几种模式:

Context.MODE_PRIVATE:应用私有,只有相同的UID才能进行读写
Context.MODE_MULTI_PROCESS:多进程安全标志,Android2.3之前该标志是默认被设置的,Android2.3开始需要自己设置。
MODE_APPEND:首次创建时如果文件存在不会删除文件。
注意这些模式可以使用位与进行设置,比如MODE_PRIVATE | MODE_APPEND。

四、是否支持进程同步

  • SharedPreferences不支持进程同步
  • MODE_MULTI_PROCESS的作用是什么?
    • 在getSharedPreferences的时候, 会强制让SP进行一次读取操作,从而保证数据是最新的. 但是若频繁多进程进行读写 . 若某个进程持有了一个外部sp对象, 那么不能保证数据是最新的. 因为刚刚被别的进程更新了.
  • 考虑用ContentProvider来实现SharedPreferences的进程同步.
    • ContentProvider基于Binder,不存在进程间互斥问题,对于同步,也做了很好的封装,不需要开发者额外实现。
    • 另外ContentProvider的每次操作都会重新getSP. 保证了sp的一致性.

小结

  1. 基本使用以及适用范围
    • 适合:保存少量的数据,且这些数据的格式简单,适用保存应用的配置参数,但不建议使用 SP 来存储大规模数据,可能会降低性能
  2. 核心原理以及源码分析
    • 基于 XML 文件存储的 key-value 键值对数据,在 /data/data/<package name>/shared_prefs目录下。
    • SharedPreferences 本身只能获取数据而不支持存储和修改,存储修改是通过 SharedPreferences.Editor 来实现的,它们两个都只是接口,真正的实现在 SharedPreferencesImpl 和 EditorImpl 。
    • ContextImpl.class对象,一个进程只存在一个该对象实例,里面的静态变量ArrayMap<String, ArrayMap<File, SharedPreferencesImpl>> sSharedPrefsCache保存了同一个进程内的所有 SharedPreferences 都保存在这个静态列表中
    • 可以稍微总结一下,sSharedPrefsCache 会保存加载到内存中的 SharedPreferences 对象,当用户需要获取 SP 对象的时候,首先会在 sSharedPrefsCache 中查找,如果没找到,就创建一个新的 SP 对象添加到 sSharedPrefsCache 中,并且以当前应用的包名为 key。
    • 需要注意的是,ContextImpl 类中并没有定义将 SharedPreferences 对象移除 sSharedPrefsCache 的方法,所以一旦加载到内存中,就会存在直至进程销毁。相对的,也就是说,SP 对象一旦加载到内存,后面任何时间使用,都是从内存中获取,不会再出现读取磁盘的情况。
    • 原来是根据传入的 file 从 ArrayMap<File, SharedPreferencesImpl> 拿到 SharedPreferences(SharedPreferencesImpl) 实例。关键代码其实并不多,但是我还是把所有代码都贴上了,因为这里我们能看到一个兼容性问题以及多进程问题,兼容性问题是指如果在 Android O 及更高版本中,通过传入的 file 拿到的 SharedPreferences 实例为空,说明该文件目录是用户无权限访问的,会直接抛出一个异常。多进程问题是指在 Context.MODE_MULTI_PROCESS 下,可能存在记录丢失的情况。
    • 果然,它是在子线程读取的磁盘文件,所以说 SP 对象初始化过程本身的确不会造成主线程的阻塞。但是真的不会阻塞嘛?这里需要注意,在读取完磁盘文件后,把 mLoaded 置为 true,继续往下看。
    • 从上面代码可知,只有子线程从磁盘加载完数据之后,mLoaded 才会被置为 true,所以说虽然从磁盘读取数据是在子线程中进行并不会阻塞主线程,但是如果文件在读取之前获取某个 SharedPreferences 的值,那么主线程就可能被阻塞住,直到子线程加载完文件为止,所以说保存的 SP 文件不宜太大。
    • EditorImpl 就是 Editor 真正的实现类,在这里面我们能看到我们经常使用的 putXxx 方法
    • 然后就是执行提交操作了,分两种,一种是 commit,一种是 apply,这里我把两个方法放在一块展示,便于查看区别:EditorImpl#commit / apply:
      • commit: 提交修改到内存,然后通过enqueueDiskWrite 将要写入磁盘的任务进行排队,commit 的写磁盘任务就直接在当前线程即 UI 线程里执行
      • apply:提交修改到内存,然后通过enqueueDiskWrite 将要写入磁盘的任务进行排队,然后QueuedWork.queue(runnable)将开启异步线程写入磁盘。这里可能会出现异步产生的文件不一致的问题。如何保证数据的一致性呢?
      • commit/apply数据一致性保证:在这两个方法里都有 mcr.writtenToDiskLatch.await(),它其实是一个 CountDownLatch。CountDownLatch 是一个同步工具类,它允许一个或多个线程一直等待,直到其他线程的操作执行完成后在执行。
    • 总结:
      1. sSharedPrefsCache 是一个 ArrayMap<String,ArrayMap<File,SharedPreferencesImpl>>,它会保存加载到内存中的 SharedPreferences 对象,ContextImpl 类中并没有定义将 SharedPreferences 对象移除 sSharedPrefsCache 的方法,所以一旦加载到内存中,就会存在直至进程销毁。相对的,也就是说,SP 对象一旦加载到内存,后面任何时间使用,都是从内存中获取,不会再出现读取磁盘的情况
      2. SharedPreferences 和 Editor 都只是接口,真正的实现在 SharedPreferencesImpl 和 EditorImpl ,SharedPreferences 只能读数据,它是在内存中进行的,Editor 则负责存数据和修改数据,分为内存操作和磁盘操作
      3. 获取 SP 只能通过 ContextImpl#getSharedPerferences 来获取,它里面首先通过 mSharedPrefsPaths 根据传入的 name 拿到 File ,然后根据 File 从 ArrayMap<File, SharedPreferencesImpl> cache 里取出对应的 SharedPrederenceImpl 实例
      4. SharedPreferencesImpl 实例化的时候会启动子线程来读取磁盘文件。但是在此之前如果通过 SharedPreferencesImpl#getXxx 或者 SharedPreferences.Editor 会阻塞 UI 线程,因为在从 SP 文件中读取数据或者往 SP 文件中写入数据的时候必须等待 SP 文件加载完
      5. 在 EditorImpl 中 putXxx 的时候,是通过 HashMap 来存储数据,提交的时候分为 commit 和 apply,它们都会把修改先提交到内存中,然后在写入磁盘中。
      6. 只不过 apply 是异步写磁盘,而 commit 可能是同步写磁盘也可能是异步写磁盘,在于前面是否还有写磁盘任务。对于 apply 和 commit 的同步,是通过 CountDownLatch 来实现的,它是一个同步工具类,它允许一个线程或多个线程一致等待,直到其他线程的操作执行完之后才执行
      7. SP 的读写操作是线程安全的,它对 mMap 的读写操作用的是同一把锁,考虑到 SP 对象的生命周期与进程一致,一旦加载到内存中就不会再去读取磁盘文件,所以只要保证内存中的状态是一致的,就可以保证读写的一致性
  3. 注意事项以及优化建议
    1. 强烈建议不要在 SP 里面存储特别大的 key/value ,有助于减少卡顿 / ANR
    2. 请不要高频的使用 apply,尽可能的批量提交;commit 直接在主线程操作时,更要注意了
    3. 不要使用 MODE_MULTI_PROCESS
    4. 高频写操作的 key 与高频读操作的 key 可以适当的拆分文件,以减少同步锁竞争
    5. 不要连续多次 edit,每次 edit 就是打开一次文件,应该获取一次 edit,然后多次执行 putXxx,减少内存波动,所以在封装方法的时候要注意了

附:参考博客链接:https://blog.csdn.net/u012619640/article/details/50940074

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值