Android PropertyInvalidatedCache分析

PropertyInvalidatedCache类说明

  该类是为了优化改动不频繁的数据在进程间交互使用的。Android大多进程间交互,使用了Binder机制。每次交互数据都需要Binder调用,但是对于那些很少改动的数据,每次都使用Binder调用,开销是不小的。该类会在首次Binder调用之后,会将结果缓存到本地,之后如果Server端数据一直都没有改动,那Client就会一直使用本地缓存。如果数据发生了改变,那么下次客户端再获取数据的时候,又会通过Binder调用获取最新的数据,再缓存,后续又会使用缓存数据。数据如果发生了改变,Client端是怎么发现数据改变了呢?这个是通过全局属性SystemProperties来实现的。在数据发生改变的时候,会去更改系统属性的值,Client端去获取数据的时候,会去比较系统属性值是否发生了变化,如果发生了变化,就会使用Binder交互,重新获取新的数据。
  并且该类是标记了hide,普通的应用APP开发是没法使用的。

初始化

    /**
     * Make a new property invalidated cache.
     *
     * @param maxEntries Maximum number of entries to cache; LRU discard
     * @param propertyName Name of the system property holding the cache invalidation nonce
     * @param cacheName Name of this cache in debug and dumpsys
     */
    public PropertyInvalidatedCache(int maxEntries, @NonNull String propertyName,
            @NonNull String cacheName) {
        mPropertyName = propertyName;
        mCacheName = cacheName;
        mMaxEntries = maxEntries;
        mCache = new LinkedHashMap<Query, Result>(
            2 /* start small */,
            0.75f /* default load factor */,
            true /* LRU access order */) {
                @Override
                protected boolean removeEldestEntry(Map.Entry eldest) {
                    final int size = size();
                    if (size > mHighWaterMark) {
                        mHighWaterMark = size;
                    }
                    if (size > maxEntries) {
                        mMissOverflow++;
                        return true;
                    }
                    return false;
                }
            };
        synchronized (sCorkLock) {
            sCaches.put(this, null);
            if (sDisabledKeys.contains(mCacheName)) {
                disableInstance();
            }
        }
    }

  初始化成员变量mPropertyName 、mCacheName 、mMaxEntries 、mCache 。
  mPropertyName 是属性名称,这个就是全局属性变量的key,是通过构造函数的参数传递进来。Client端发现Server端的数据发生变化,就是通过读取该属性的值发生变化知道的。该名字通过类注释可以发现按照惯例是以“cache_key.”前缀。
  mCacheName 是缓存名,进程中应该唯一。进程中通过禁止该类缓存功能就是通过它的名字去查找的。
  mMaxEntries 是当前缓存的最大数量,是由参数maxEntries传递进来的。如果超过了该值,会将最后访问到的缓存,给删除。
  mCache 是用LinkedHashMap实现的缓存。并且该缓存超过了总量之后,再删除是按照获取的最后访问顺序进行删除的。

查询

    /**
     * Get a value from the cache or recompute it.
     */
    public Result query(Query query) {
        // Let access to mDisabled race: it's atomic anyway.
        long currentNonce = (!isDisabledLocal()) ? getCurrentNonce() : NONCE_DISABLED;
        for (;;) {
            if (currentNonce == NONCE_DISABLED || currentNonce == NONCE_UNSET
                    || currentNonce == NONCE_CORKED || bypass(query)) {
                if (!mDisabled) {
                    // Do not bother collecting statistics if the cache is
                    // locally disabled.
                    synchronized (mLock) {
                        mSkips[(int) currentNonce]++;
                    }
                }

                if (DEBUG) {
                    if (!mDisabled) {
                        Log.d(TAG, String.format(
                            "cache %s %s for %s",
                            cacheName(), sNonceName[(int) currentNonce], queryToString(query)));
                    }
                }
                return recompute(query);
            }
            final Result cachedResult;
            synchronized (mLock) {
                if (currentNonce == mLastSeenNonce) {
                    cachedResult = mCache.get(query);

                    if (cachedResult != null) mHits++;
                } else {
                    if (DEBUG) {
                        Log.d(TAG, String.format(
                            "clearing cache %s of %d entries because nonce changed [%s] -> [%s]",
                            cacheName(), mCache.size(),
                            mLastSeenNonce, currentNonce));
                    }
                    clear();
                    mLastSeenNonce = currentNonce;
                    cachedResult = null;
                }
            }
            // Cache hit --- but we're not quite done yet.  A value in the cache might need to
            // be augmented in a "refresh" operation.  The refresh operation can combine the
            // old and the new nonce values.  In order to make sure the new parts of the value
            // are consistent with the old, possibly-reused parts, we check the property value
            // again after the refresh and do the whole fetch again if the property invalidated
            // us while we were refreshing.
            if (cachedResult != null) {
                final Result refreshedResult = refresh(cachedResult, query);
                if (refreshedResult != cachedResult) {
                    if (DEBUG) {
                        Log.d(TAG, "cache refresh for " + cacheName() + " " + queryToString(query));
                    }
                    final long afterRefreshNonce = getCurrentNonce();
                    if (currentNonce != afterRefreshNonce) {
                        currentNonce = afterRefreshNonce;
                        if (DEBUG) {
                            Log.d(TAG, String.format("restarting %s %s because nonce changed in refresh",
                                                     cacheName(),
                                                     queryToString(query)));
                        }
                        continue;
                    }
                    synchronized (mLock) {
                        if (currentNonce != mLastSeenNonce) {
                            // Do nothing: cache is already out of date. Just return the value
                            // we already have: there's no guarantee that the contents of mCache
                            // won't become invalid as soon as we return.
                        } else if (refreshedResult == null) {
                            mCache.remove(query);
                        } else {
                            mCache.put(query, refreshedResult);
                        }
                    }
                    return maybeCheckConsistency(query, refreshedResult);
                }
                if (DEBUG) {
                    Log.d(TAG, "cache hit for " + cacheName() + " " + queryToString(query));
                }
                return maybeCheckConsistency(query, cachedResult);
            }
            // Cache miss: make the value from scratch.
            if (DEBUG) {
                Log.d(TAG, "cache miss for " + cacheName() + " " + queryToString(query));
            }
            final Result result = recompute(query);
            synchronized (mLock) {
                // If someone else invalidated the cache while we did the recomputation, don't
                // update the cache with a potentially stale result.
                if (mLastSeenNonce == currentNonce && result != null) {
                    mCache.put(query, result);
                }
                mMisses++;
            }
            return maybeCheckConsistency(query, result);
        }
    }

  这个方法是理解PropertyInvalidatedCache类的关键,里面描述了什么时候从本地缓存取出数据,什么时候去Server端获取数据,怎么缓存的。
  开始就先获取当前currentNonce,获取的时候,会去检查当前是不是被禁止的状态,如果是,就不能使用本地缓存,每次都需要去Server端获取数据。禁止状态是通过isDisabledLocal()方法判断的。这个方法后面再说,接着向下看。如果是禁止的状态,currentNonce的值为NONCE_DISABLED,在该状态下,就会调用recompute(query)方法,来返回结果。这个方法,是留给实现子类,一般都是向Server端请求数据。
  如果不是禁止状态,就会调用getCurrentNonce()方法返回currentNonce的值。该方法就是用来返回全局属性变量的值。如果没有设置该变量的值,会返回NONCE_UNSET。在该状态下,也是不会使用本地缓存的。
  从代码里还可以看到一个状态NONCE_CORKED,在该状态下也不使用缓存。后续再说这个。
  不适用缓存的情况,还有一种,就是bypass(query)返回true。这个也是留给实现子类改写的。默认值返回false,是需要使用本地缓存的。
  如果上面的情况都不符合,会继续向下走,去获取缓存。在代码29行,对currentNonce与mLastSeenNonce判断是否相等,如果相等,就会去缓存mCache中取值。mLastSeenNonce代表上一次获取属性的值,如果本地和上次获取的属性值一样,代表值没有发生改变,可以使用缓存中的值。如果不等,那说明值已经发生改变,本地缓存没法使用,后续需要重新获取新值。同时,还会清除缓存,并且更新mLastSeenNonce,并将cachedResult赋值为null,以便后续重新获取新值。
  接着往下看,代码51行,cachedResult不为null的情况,这种情况是缓存命中的情况。不过接着就走到了refresh(cachedResult, query)方法,这个是"refresh" 操作。

    /**
     * Make result up-to-date on a cache hit.  Called unlocked;
     * may block.
     *
     * Return either 1) oldResult itself (the same object, by reference equality), in which
     * case we just return oldResult as the result of the cache query, 2) a new object, which
     * replaces oldResult in the cache and which we return as the result of the cache query
     * after performing another property read to make sure that the result hasn't changed in
     * the meantime (if the nonce has changed in the meantime, we drop the cache and try the
     * whole query again), or 3) null, which causes the old value to be removed from the cache
     * and null to be returned as the result of the cache query.
     */
    protected Result refresh(Result oldResult, Query query) {
        return oldResult;
    }

  看到这个方法的注释,可能返回三个结果。
  1)原来的对象,这个对象就会作为这个query()方法的返回结果
  2)一个新的对象,后续会在判断在这个"refresh" 操作完成之后,判断属性是否发生了变化,如果属性发生变化,会丢弃这个缓存,并且再试一遍整个query。如果属性没有发生变化,会将这个新对象替换原来的旧缓存。并且将该新对象作为结果返回。
  3)null,后续会在判断在这个"refresh" 操作完成之后,判断属性是否发生了变化,如果属性发生变化,会丢弃这个缓存,并且再试一遍整个query。如果属性没有发生变化,会删除原来的旧缓存。并且将null作为结果返回。
  但是看这个方法的具体实现,就直接将原缓存对象oldResult返回了。可能就是为了在具体子类中再实现该方法。
  再返回query方法,53行到83行,就是处理了上面三种返回情况,所说的操作。
  接着往下看89行,就是处理缓存没有命中的情况,就是调用recompute(query)得到返回结果。之前也说了,这个方法一般都是向Server端请求数据。
  得到结果以后,判断mLastSeenNonce和currentNonce相等并且结果不为null的情况下,将结果放入缓存中。然后再将结果返回。

禁止状态

看下这个方法

    /**
     * Return whether the cache is disabled in this process.
     */
    public final boolean isDisabledLocal() {
        return mDisabled || !sEnabled;
    }

  其中,mDisabled是该类的成员变量,而sEnabled是一个静态变量。所以mDisabled是控制局部禁止,而sEnabled是用来控制进程全局的。
  mDisabled是用disableInstance()来设置的。如下,并且该方法会调用clear()将缓存清理掉。

    /**
     * Disable the use of this cache in this process.
     */
    public final void disableInstance() {
        synchronized (mLock) {
            mDisabled = true;
            clear();
        }
    }

  sEnabled是一个静态私有变量,设置该变量是以下方法,不过要说明一下的是,如果设置了全局禁止,是没法再次打开,因为没有提供具体实现方法。

    /**
     * Disable all caches in the local process.  Once disabled it is not
     * possible to re-enable caching in the current process.
     */
    @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
    public static void disableForTestMode() {
        Log.d(TAG, "disabling all caches in the process");
        sEnabled = false;
    }

CORKED NONCE

  临时的将缓存放入未初始化状态,并且阻止无效数据移出那个状态。这个是为了避免在短时间进行大量缓存数据无效的开销。在该状态下,Client端是直接与Server端进行数据交互的。
  关于它的两个方法是corkInvalidations(@NonNull String name)和uncorkInvalidations(@NonNull String name)。一个是设置CORKED NONCE,一个是取消CORKED NONCE。并且这两应该配对使用。
  上面这个是类注释里面的大致意思,不过还是需要解释一下到底是什么意思。
  这个状态的设置应该是在Server端。在数据量很大的情况下,Server端更新数据是需要一段时间的,并且是在更新数据完毕才设置数据无效全局属性。在这个更新数据期间之内,如果不设置CORKED NONCE,Client端是不知道数据是正在更新的,也就是Client端的原来的缓存已经是无效的状态了。如果这个时候客户端通过query()方法调用获取数据,这个时候就是无效缓存数据。如果加上CORKED NONCE,Server端则在开始更新数据前会先调用corkInvalidations(@NonNull String name)设置CORKED NONCE,等到更新结束,再调用uncorkInvalidations(@NonNull String name)去除CORKED NONCE。这样Client在Server更新数据的时候,通过query()方法就能知道,现在是CORKED NONCE,需要去Server端重新获取数据。并且这个状态和NONCE_UNSET和NONCE_DISABLED是一样都不会去更新本地的缓存的。可以对照query()方法的代码看一下这个逻辑。
  再看下corkInvalidations(@NonNull String name):

    /**
     * Temporarily put the cache in the uninitialized state and prevent invalidations from
     * moving it out of that state: useful in cases where we want to avoid the overhead of a
     * large number of cache invalidations in a short time.  While the cache is corked, clients
     * bypass the cache and talk to backing services directly.  This property makes corking
     * correctness-preserving even if corked outside the lock that controls access to the
     * cache's backing service.
     *
     * corkInvalidations() and uncorkInvalidations() must be called in pairs.
     *
     * @param name Name of the cache-key property to cork
     */
    public static void corkInvalidations(@NonNull String name) {
        if (!sEnabled) {
            if (DEBUG) {
                Log.w(TAG, String.format(
                    "cache cork %s suppressed", name));
            }
            return;
        }

        synchronized (sCorkLock) {
            int numberCorks = sCorks.getOrDefault(name, 0);
            if (DEBUG) {
                Log.d(TAG, String.format("corking %s: numberCorks=%s", name, numberCorks));
            }

            // If we're the first ones to cork this cache, set the cache to the corked state so
            // existing caches talk directly to their services while we've corked updates.
            // Make sure we don't clobber a disabled cache value.

            // TODO(dancol): we can skip this property write and leave the cache enabled if the
            // caller promises not to make observable changes to the cache backing state before
            // uncorking the cache, e.g., by holding a read lock across the cork-uncork pair.
            // Implement this more dangerous mode of operation if necessary.
            if (numberCorks == 0) {
                final long nonce = SystemProperties.getLong(name, NONCE_UNSET);
                if (nonce != NONCE_UNSET && nonce != NONCE_DISABLED) {
                    SystemProperties.set(name, Long.toString(NONCE_CORKED));
                }
            } else {
                final long count = sCorkedInvalidates.getOrDefault(name, (long) 0);
                sCorkedInvalidates.put(name, count + 1);
            }
            sCorks.put(name, numberCorks + 1);
            if (DEBUG) {
                Log.d(TAG, "corked: " + name);
            }
        }
    }

  可见,在首次设置的时候,如果当前不是NONCE_UNSET 和NONCE_DISABLED,则会将全局属性值设置为NONCE_CORKED。如果在还没有取消CORKED的时候,再次接着调用该方法,会记录设置次数。sCorks就是用来记录当前设置的次数。
  还有个sCorkedInvalidates这个是用来记录缓存已经是corked,再次无效或cork缓存的空操作的次数。
  再看下uncorkInvalidations(@NonNull String name):

    /**
     * Undo the effect of a cork, allowing cache invalidations to proceed normally.
     * Removing the last cork on a cache name invalidates the cache by side effect,
     * transitioning it to normal operation (unless explicitly disabled system-wide).
     *
     * @param name Name of the cache-key property to uncork
     */
    public static void uncorkInvalidations(@NonNull String name) {
        if (!sEnabled) {
            if (DEBUG) {
                Log.w(TAG, String.format(
                    "cache uncork %s suppressed", name));
            }
            return;
        }

        synchronized (sCorkLock) {
            int numberCorks = sCorks.getOrDefault(name, 0);
            if (DEBUG) {
                Log.d(TAG, String.format("uncorking %s: numberCorks=%s", name, numberCorks));
            }

            if (numberCorks < 1) {
                throw new AssertionError("cork underflow: " + name);
            }
            if (numberCorks == 1) {
                sCorks.remove(name);
                invalidateCacheLocked(name);
                if (DEBUG) {
                    Log.d(TAG, "uncorked: " + name);
                }
            } else {
                sCorks.put(name, numberCorks - 1);
            }
        }
    }

  这个方法是和corkInvalidations(@NonNull String name)相反的操作,在最后一次numberCorks == 1的时候,会设置缓存无效的状态invalidateCacheLocked(name)。缓存无效见下面,先说下自动cork。

自动cork

  在该类中,还提供了一个自动cork的帮助类AutoCorker。这个类允许缓存数据提供者分摊这个缓存无效的花费,通过改变之后立即cork。并且在之后的一段时间自动uncork。
  最好是用之前提到的corkInvalidations(@NonNull String name)和uncorkInvalidations(@NonNull String name)成对明确调用。但是,有一些无效批次明确出现不太现实,这时候AutoCorker就是一个具体的选择。
  这个类有个延迟时间是可以配置的,但是不能配置太大,这个时间的目的是为了让Server写这个属性值的时间减小到最小。每50ms写一次不伤害系统性能。

AutoCorker初始化

        public AutoCorker(@NonNull String propertyName) {
            this(propertyName, DEFAULT_AUTO_CORK_DELAY_MS);
        }

        public AutoCorker(@NonNull String propertyName, int autoCorkDelayMs) {
            mPropertyName = propertyName;
            mAutoCorkDelayMs = autoCorkDelayMs;
            // We can't initialize mHandler here: when we're created, the main loop might not
            // be set up yet! Wait until we have a main loop to initialize our
            // corking callback.
        }

  第一个是将延迟时间默认设置成50ms。

autoCork()

        public void autoCork() {
            if (Looper.getMainLooper() == null) {
                // We're not ready to auto-cork yet, so just invalidate the cache immediately.
                if (DEBUG) {
                    Log.w(TAG, "invalidating instead of autocorking early in init: "
                            + mPropertyName);
                }
                PropertyInvalidatedCache.invalidateCache(mPropertyName);
                return;
            }
            synchronized (mLock) {
                boolean alreadyQueued = mUncorkDeadlineMs >= 0;
                if (DEBUG) {
                    Log.w(TAG, String.format(
                            "autoCork %s mUncorkDeadlineMs=%s", mPropertyName,
                            mUncorkDeadlineMs));
                }
                mUncorkDeadlineMs = SystemClock.uptimeMillis() + mAutoCorkDelayMs;
                if (!alreadyQueued) {
                    getHandlerLocked().sendEmptyMessageAtTime(0, mUncorkDeadlineMs);
                    PropertyInvalidatedCache.corkInvalidations(mPropertyName);
                } else {
                    final long count = sCorkedInvalidates.getOrDefault(mPropertyName, (long) 0);
                    sCorkedInvalidates.put(mPropertyName, count + 1);
                }
            }
        }

  autoCork()方法是一个主要的方法,每次会在更改数据前调用它。在第一次执行的时候,alreadyQueued为false,所以会发送一个延迟设置时间的消息(默认50ms),接着就会调用PropertyInvalidatedCache.corkInvalidations(mPropertyName)用来设置corked nunce。可见,如果在消息延迟时间还没到的时候,再次调用autoCork(),这时alreadyQueued 为true,并且又会更改mUncorkDeadlineMs 的值。
  再接着看看处理消息:

        private void handleMessage(Message msg) {
            synchronized (mLock) {
                if (DEBUG) {
                    Log.w(TAG, String.format(
                            "handleMsesage %s mUncorkDeadlineMs=%s",
                            mPropertyName, mUncorkDeadlineMs));
                }

                if (mUncorkDeadlineMs < 0) {
                    return;  // ???
                }
                long nowMs = SystemClock.uptimeMillis();
                if (mUncorkDeadlineMs > nowMs) {
                    mUncorkDeadlineMs = nowMs + mAutoCorkDelayMs;
                    if (DEBUG) {
                        Log.w(TAG, String.format(
                                        "scheduling uncork at %s",
                                        mUncorkDeadlineMs));
                    }
                    getHandlerLocked().sendEmptyMessageAtTime(0, mUncorkDeadlineMs);
                    return;
                }
                if (DEBUG) {
                    Log.w(TAG, "automatic uncorking " + mPropertyName);
                }
                mUncorkDeadlineMs = -1;
                PropertyInvalidatedCache.uncorkInvalidations(mPropertyName);
            }
        }

        @GuardedBy("mLock")
        private Handler getHandlerLocked() {
            if (mHandler == null) {
                mHandler = new Handler(Looper.getMainLooper()) {
                        @Override
                        public void handleMessage(Message msg) {
                            AutoCorker.this.handleMessage(msg);
                        }
                    };
            }
            return mHandler;
        }

  主要在handleMessage(Message msg)中,如果mUncorkDeadlineMs 比现在的时间大,说明中间又调用了autoCork(),所以接着再发送一个延迟消息。如果小于等于现在时间,表明时间到了,应该uncork。所以,将 mUncorkDeadlineMs = -1,PropertyInvalidatedCache.uncorkInvalidations(mPropertyName)。将mUncorkDeadlineMs = -1是为了后续接着调用该自动类的时候,就再实现发送延迟消息。

缓存无效

  再接着看下invalidateCache(@NonNull String name)

    /**
     * Invalidate PropertyInvalidatedCache caches in all processes that are keyed on
     * {@var name}. This function is synchronous: caches are invalidated upon return.
     *
     * @param name Name of the cache-key property to invalidate
     */
    public static void invalidateCache(@NonNull String name) {
        if (!sEnabled) {
            if (DEBUG) {
                Log.w(TAG, String.format(
                    "cache invalidate %s suppressed", name));
            }
            return;
        }

        // Take the cork lock so invalidateCache() racing against corkInvalidations() doesn't
        // clobber a cork-written NONCE_UNSET with a cache key we compute before the cork.
        // The property service is single-threaded anyway, so we don't lose any concurrency by
        // taking the cork lock around cache invalidations.  If we see contention on this lock,
        // we're invalidating too often.
        synchronized (sCorkLock) {
            Integer numberCorks = sCorks.get(name);
            if (numberCorks != null && numberCorks > 0) {
                if (DEBUG) {
                    Log.d(TAG, "ignoring invalidation due to cork: " + name);
                }
                final long count = sCorkedInvalidates.getOrDefault(name, (long) 0);
                sCorkedInvalidates.put(name, count + 1);
                return;
            }
            invalidateCacheLocked(name);
        }
    }

  在sCorks中对应的属性名大于0的时候,是不会实际去设置属性值的,只会记录一次nops操作次数在sCorkedInvalidates中,因为这个时候是在corked,等会会通过uncorkInvalidations(@NonNull String name)方法设置。
接着看invalidateCacheLocked(name)

    @GuardedBy("sCorkLock")
    private static void invalidateCacheLocked(@NonNull String name) {
        // There's no race here: we don't require that values strictly increase, but instead
        // only that each is unique in a single runtime-restart session.
        final long nonce = SystemProperties.getLong(name, NONCE_UNSET);
        if (nonce == NONCE_DISABLED) {
            if (DEBUG) {
                Log.d(TAG, "refusing to invalidate disabled cache: " + name);
            }
            return;
        }

        long newValue;
        do {
            newValue = NoPreloadHolder.next();
        } while (newValue >= 0 && newValue < NONCE_RESERVED);
        final String newValueString = Long.toString(newValue);
        if (DEBUG) {
            Log.d(TAG,
                    String.format("invalidating cache [%s]: [%s] -> [%s]",
                            name,
                            nonce,
                            newValueString));
        }
        // TODO(dancol): add an atomic compare and exchange property set operation to avoid a
        // small race with concurrent disable here.
        SystemProperties.set(name, newValueString);
        long invalidateCount = sInvalidates.getOrDefault(name, (long) 0);
        sInvalidates.put(name, ++invalidateCount);
    }

  这个是无效缓存属性值的方法。可以看到NoPreloadHolder.next()每次产生新值,设置到属性中。咱们知道,无效的情况就是上次的属性值和当前的属性值不一样,看看它是怎么实现生成的新值和之前的不一样的。

    // Inner class avoids initialization in processes that don't do any invalidation
    private static final class NoPreloadHolder {
        private static final AtomicLong sNextNonce = new AtomicLong((new Random()).nextLong());
        public static long next() {
            return sNextNonce.getAndIncrement();
        }
    }

  看到了吧,随机生成了一个随机数,然后封装成了一个AtomicLong 对象。以后加一,这样,就会和上次的不一样了。当然,同一个属性key的值,不见得会每次相差1,因为可能存在多个key对应的缓存无效。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值