Launcher3 中 IconCache 缓存逻辑

概述

我们先看下IconCache的初始化过程,接着看下IconCache核心数据结构、算法,最后介绍与之关联的几个类。

Launcher.java

public class Launcher extends StatefulActivity<LauncherState> implements ... {
    ...
    public static final String TAG = "Launcher";
    private LauncherModel mModel;
    ...
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...
        LauncherAppState app = LauncherAppState.getInstance(this);
        mOldConfig = new Configuration(getResources().getConfiguration());
        mModel = app.getModel();
        ...
       }
   }
  • 这个类是Launcher的主入口,即 MainActivity。onCreate()做了许多界面和管理器的初始化。

  • 这里我们关注是初始化了 LauncherAppStateLauncherModel

LauncherAppState.java

public class LauncherAppState {
    // 注释1
    // We do not need any synchronization for this variable as its only written on UI thread.
    public static final MainThreadInitializedObject<LauncherAppState> INSTANCE =
            new MainThreadInitializedObject<>(LauncherAppState::new);

    private final Context mContext;
    private final LauncherModel mModel;
    private final IconProvider mIconProvider;
    private final IconCache mIconCache;
    private final DatabaseWidgetPreviewLoader mWidgetCache;
    private final InvariantDeviceProfile mInvariantDeviceProfile;
    private final RunnableList mOnTerminateCallback = new RunnableList();

    public static LauncherAppState getInstance(final Context context) {
        return INSTANCE.get(context);
    }

    public LauncherAppState(Context context) {
        // 注释2
        this(context, LauncherFiles.APP_ICONS_DB);
        ...
    }

   public LauncherAppState(Context context, @Nullable String iconCacheFileName) {
       ...
       // 注释3
       mIconCache = new IconCache(mContext, mInvariantDeviceProfile,
       iconCacheFileName, mIconProvider);
       ...
   }
}
  • 注释1:LauncherAppState 是单例,且限定在主线程上初始化

  • 注释2 注释3 传入数据库名字 app_icon.db,进而初始化 IconCache

  • mModel 即数据管理器,用于维护启动器的内存状态。预计静态中应该只有一个LauncherModel对象。还提供用于更新 Launcher 的数据库状态的 API。

  • mIconCache应用程序icon和title的缓存,图标可以由任何线程创建。

  • mWidgetCache 存储widget预览信息的数据库

LoaderTask.java

是有数据管理类LauncherModel来调用的,其核心是Run方法。

主要分为四大步骤,并开启事务机制来管理

  1. 加载与绑定桌面内容

    1. loadWorkspace

    2. sanitizeData

    3. bindWorkspace

    4. sendFirstScreenActiveInstallsBroadcast

  2. 加载和绑定所有的应用图标和信息

    1. loadAllApps

    2. bindAllApps

    3. update icon cache 对应图标缓存逻辑类 LauncherActivityCachingLogic

    4. save shortcuts in icon cache

    这一步实际是在第一步的,对应的图标缓存逻辑类 ShortcutCachingLogic

  3. 加载和绑定所有DeepShortcuts

    1. loadDeepShortcuts

    2. bindDeepShortcuts

    3. save deep shortcuts in icon cache 对应的图标缓存逻辑类 ShortcutCachingLogic

  4. 加载和绑定所有的Widgets

    1. load widgets

    2. bindWidgets

    3. save widgets in icon cache 对应的图标缓存逻辑类 ComponentWithIconCachingLogic

IconCacheUpdateHandler.java

IconCacheUpdateHandler扫描到所有应用后,会开启一个线程 SerializedIconUpdateTask进行更新图标操作,把图标缓存到内存和数据库里。

调用流程

  • 在上面LoaderTask过程中更新图标用的是IconCacheUpdateHandler.updateIcons()

  • 这是个工具类,处理更新图标缓存, 处理业务与IconCache的连接

  • 内部类 SerializedIconUpdateTask 序列化图标更新任务,即将这些图标信息存储或者更新到数据库中

举例说明过程

updateIcons

public <T> void updateIcons(List<T> apps, CachingLogic<T> cachingLogic,
        OnUpdateCallback onUpdateCallback) {
    // Filter the list per user
    HashMap<UserHandle, HashMap<ComponentName, T>> userComponentMap = new HashMap<>();
    int count = apps.size();
    for (int i = 0; i < count; i++) {
        T app = apps.get(i);
        UserHandle userHandle = cachingLogic.getUser(app);
        HashMap<ComponentName, T> componentMap = userComponentMap.get(userHandle);
        if (componentMap == null) {
            componentMap = new HashMap<>();
            userComponentMap.put(userHandle, componentMap);
        }
        componentMap.put(cachingLogic.getComponent(app), app);
    }

    for (Entry<UserHandle, HashMap<ComponentName, T>> entry : userComponentMap.entrySet()) {
        updateIconsPerUser(entry.getKey(), entry.getValue(), cachingLogic, onUpdateCallback);
    }

    // From now on, clear every valid item from the global valid map.
    mFilterMode = MODE_CLEAR_VALID_ITEMS;
}
  • 这里有两个Map,按照用户维度来分组组件

    • 按照用户维度来分组组件 HashMap<UserHandle, HashMap<ComponentName, T>> userComponentMap;

    • 按照组件不同分组 HashMap<ComponentName, T> componentMap = userComponentMap.get(userHandle);

updateIconsPerUser


/**
 * Updates the persistent DB, such that only entries corresponding to {@param apps} remain in
 * the DB and are updated.
 * @return The set of packages for which icons have updated.
 */
@SuppressWarnings("unchecked")
private <T> void updateIconsPerUser(UserHandle user, HashMap<ComponentName, T> componentMap,
        CachingLogic<T> cachingLogic, OnUpdateCallback onUpdateCallback) {
    Set<String> ignorePackages = mPackagesToIgnore.get(user);
    if (ignorePackages == null) {
        ignorePackages = Collections.emptySet();
    }
    long userSerial = mIconCache.getSerialNumberForUser(user);
    Log.d(TAG, "updateIconsPerUser: userSerial = " + userSerial + " ,componentMap =" + componentMap.size());

    Stack<T> appsToUpdate = new Stack<>();
    try (Cursor c = mIconCache.mIconDb.query(
            new String[]{IconDB.COLUMN_ROWID, IconDB.COLUMN_COMPONENT,
                    IconDB.COLUMN_LAST_UPDATED, IconDB.COLUMN_VERSION,
                    IconDB.COLUMN_SYSTEM_STATE},
            IconDB.COLUMN_USER + " = ? ",
            new String[]{Long.toString(userSerial)})) {

        final int indexComponent = c.getColumnIndex(IconDB.COLUMN_COMPONENT);
        final int indexLastUpdate = c.getColumnIndex(IconDB.COLUMN_LAST_UPDATED);
        final int indexVersion = c.getColumnIndex(IconDB.COLUMN_VERSION);
        final int rowIndex = c.getColumnIndex(IconDB.COLUMN_ROWID);
        final int systemStateIndex = c.getColumnIndex(IconDB.COLUMN_SYSTEM_STATE);

        Log.d(TAG, "updateIconsPerUser: 111");
        while (c.moveToNext()) {
            Log.d(TAG, "updateIconsPerUser: 222");
            ...
        }
    } catch (SQLiteException e) {
        Log.d(TAG, "Error reading icon cache", e);
        // Continue updating whatever we have read so far
    }

    // Insert remaining apps.
    if (!componentMap.isEmpty() || !appsToUpdate.isEmpty()) {
        Stack<T> appsToAdd = new Stack<>();
        appsToAdd.addAll(componentMap.values());
        Log.d(TAG, "SerializedIconUpdateTask appsToAdd = " + appsToAdd.size() + ", appsToUpdate = "+ appsToUpdate.size());
        new SerializedIconUpdateTask(userSerial, user, appsToAdd, appsToUpdate, cachingLogic,
                onUpdateCallback).scheduleNext();
    }
}

  • 为什么要删除操作?setIgnorePackages

SerializedIconUpdateTask.run()

private class SerializedIconUpdateTask<T> implements Runnable {
    ....
    @Override
    public void run() {
       ...
       if (!mAppsToAdd.isEmpty()) {
            T app = mAppsToAdd.pop();
            PackageInfo info = mPkgInfoMap.get(mCachingLogic.getComponent(app).getPackageName());
            // We do not check the mPkgInfoMap when generating the mAppsToAdd. Although every
            // app should have package info, this is not guaranteed by the api
            if (info != null) {
                mIconCache.addIconToDBAndMemCache(app, mCachingLogic, info,
                        mUserSerial, false /*replace existing*/);
            }

            if (!mAppsToAdd.isEmpty()) {
                scheduleNext();
            }
        }
    }

    public void scheduleNext() {
        mIconCache.mWorkerHandler.postAtTime(this, ICON_UPDATE_TOKEN,
                SystemClock.uptimeMillis() + 1);
    }
}

IconCache.java

核心思想:针对每类图标提供通用的HashMap内存缓存 + 数据库缓存,同时通过CachingLogic多种实现图标差异性。


// 加载 shortcut 图标
private synchronized <T extends ItemInfoWithIcon> void getShortcutIcon(T info, ShortcutInfo si,
        boolean useBadged, @NonNull Predicate<T> fallbackIconCheck) {
    BitmapInfo bitmapInfo;
    if (FeatureFlags.ENABLE_DEEP_SHORTCUT_ICON_CACHE.get()) {
        bitmapInfo = cacheLocked(ShortcutKey.fromInfo(si).componentName, si.getUserHandle(),
                () -> si, mShortcutCachingLogic, false, false).bitmap;
    } else {
        // If caching is disabled, load the full icon
        bitmapInfo = mShortcutCachingLogic.loadIcon(mContext, si);
    }
    if (bitmapInfo.isNullOrLowRes()) {
        bitmapInfo = getDefaultIcon(si.getUserHandle());
    }

    if (isDefaultIcon(bitmapInfo, si.getUserHandle()) && fallbackIconCheck.test(info)) {
        return;
    }
    info.bitmap = bitmapInfo;
    if (useBadged) {
        BitmapInfo badgeInfo = getShortcutInfoBadge(si);
        try (LauncherIcons li = LauncherIcons.obtain(mContext)) {
            info.bitmap = li.badgeBitmap(info.bitmap.icon, badgeInfo);
        }
    }
}

/**
 * 加载 Widget 图标
 */
public synchronized String getTitleNoCache(ComponentWithLabel info) {
    CacheEntry entry = cacheLocked(info.getComponent(), info.getUser(), () -> info,
            mComponentWithLabelCachingLogic, false /* usePackageIcon */,
            true /* useLowResIcon */);
    return Utilities.trim(entry.title);
}

BaseIconCache.java

1.首先看下构造方法
public abstract class BaseIconCache {
    ....
    private static final int INITIAL_ICON_CACHE_CAPACITY = 50;
    private final Map<ComponentKey, CacheEntry> mCache;
    ...
    public BaseIconCache(Context context, String dbFileName, Looper bgLooper,
        int iconDpi, int iconPixelSize, boolean inMemoryCache) {
        ...
        if (inMemoryCache) {
            mCache = new HashMap<>(INITIAL_ICON_CACHE_CAPACITY);
        } else {
            // Use a dummy cache
            mCache = new AbstractMap<ComponentKey, CacheEntry>() {
                @Override
                public Set<Entry<ComponentKey, CacheEntry>> entrySet() {
                    return Collections.emptySet();
                }

                @Override
                public CacheEntry put(ComponentKey key, CacheEntry value) {
                    return value;
                }
            };
        }
        ...
    }

}

// 缓存key, 组成: 组件名 和 用户UserHandle
public class ComponentKey {

    public final ComponentName componentName;
    public final UserHandle user;

    private final int mHashCode;

    public ComponentKey(ComponentName componentName, UserHandle user) {
        if (componentName == null || user == null) {
            throw new NullPointerException();
        }
        this.componentName = componentName;
        this.user = user;
        mHashCode = Arrays.hashCode(new Object[] {componentName, user});
    }
    ...
}

// 缓存Value,组成:图标 + title + contentDesc
public static class CacheEntry {

    @NonNull
    public BitmapInfo bitmap = BitmapInfo.LOW_RES_INFO;
    public CharSequence title = "";
    public CharSequence contentDescription = "";
}

  • 缓存数据结构:Map<ComponentKey, CacheEntry>
  • 缓存集合初始大小为50
  • ComponentKey:缓存key, 组成: 组件名(pkg+cls) 和 用户UserHandle
  • CacheEntry:缓存Value,组成:图标 + title + contentDesc
  • 注意这里有一段代码是虚内存,使用技巧值得学习
 // Use a dummy cache
            mCache = new AbstractMap<ComponentKey, CacheEntry>() {
                @Override
                public Set<Entry<ComponentKey, CacheEntry>> entrySet() {
                    return Collections.emptySet();
                }

                @Override
                public CacheEntry put(ComponentKey key, CacheEntry value) {
                    return value;
                }
            };
2.继续看另一个重要方法 cacheLocked()
/**
 * @param  componentName  组件名
 * @param  user 用户
 * @param  infoProvider 组件信息提供者
 * @param  cachingLogic  对应的缓存逻辑处理类
 * @param  usePackageIcon 是否使用pkg的icon
 * @param  useLowResIcon  是否使用默认的空图标
 */
protected <T> CacheEntry cacheLocked(
        @NonNull ComponentName componentName, @NonNull UserHandle user,
        @NonNull Supplier<T> infoProvider, @NonNull CachingLogic<T> cachingLogic,
        boolean usePackageIcon, boolean useLowResIcon) {
    assertWorkerThread();
    // 1.生成缓存key
    ComponentKey cacheKey = new ComponentKey(componentName, user);
    // 2.尝试根据key,从缓存中取
    CacheEntry entry = mCache.get(cacheKey);
    // 3.尚未缓存 或者 缓存了但是缓存的是空的默认图标,此时去缓存
    if (entry == null || (entry.bitmap.isLowRes() && !useLowResIcon)) {
        entry = new CacheEntry();
        //4.如果对应的缓存逻辑控制类 允许添加到内存缓存中,即存入mCache,但此时value未赋值
        if (cachingLogic.addToMemCache()) { 
            mCache.put(cacheKey, entry);
        }

        // Check the DB first.
        T object = null;
        boolean providerFetchedOnce = false;

        // 4.首先查看数据库是否存在
        // 如果数据存在,取出来赋值给entry
        // 如果数据库不存在,加载默认空图标、pkg图标、或者 cachingLogic.loadIcon
        if (!getEntryFromDB(cacheKey, entry, useLowResIcon)) {
            object = infoProvider.get();
            providerFetchedOnce = true;

            if (object != null) { // 4.1如果信息提供者不为空,直接去对应的缓存控制逻辑取图标
                entry.bitmap = cachingLogic.loadIcon(mContext, object);
            } else { // 4.2如果提供者是空的,返回默认的或者使用pkg的图标
                if (usePackageIcon) {
                    CacheEntry packageEntry = getEntryForPackageLocked(
                            componentName.getPackageName(), user, false);
                    if (packageEntry != null) {
                        if (DEBUG) Log.d(TAG, "using package default icon for " +
                                componentName.toShortString());
                        entry.bitmap = packageEntry.bitmap;
                        entry.title = packageEntry.title;
                        entry.contentDescription = packageEntry.contentDescription;
                    }
                }
                // 如果pkg依然为空,使用默认的空白图标
                if (entry.bitmap == null) {
                    if (DEBUG) Log.d(TAG, "using default icon for " +
                            componentName.toShortString());
                    entry.bitmap = getDefaultIcon(user);
                }
            }
        }

        // 5.检查并对entry的title和desc继续赋值
        if (TextUtils.isEmpty(entry.title)) {
            if (object == null && !providerFetchedOnce) {
                object = infoProvider.get();
                providerFetchedOnce = true;
            }
            if (object != null) {
                entry.title = cachingLogic.getLabel(object);
                entry.contentDescription = mPackageManager.getUserBadgedLabel(
                        cachingLogic.getDescription(object, entry.title), user);
            }
        }
    }
    return entry; // 返回缓存的Value,及CacheEntry
}

补充说明两点

  • getEntryFromDB 从数据库中查询目标 Entry

  • getEntryForPackageLocked 与上面这个方法类似,唯一多的逻辑是当从packagemanger查询到应用图标会存入到数据库

3.方法addIconToDBAndMemCache
/**
* 在数据库和内存缓存中添加一个条目。 
* @param replaceExisting 如果为真,它会重新创建位图,即使它已经存在于内存中。
* 这在以前的位图是使用旧数据创建时很有用。
*/
public synchronized <T> void addIconToDBAndMemCache(T object, CachingLogic<T> cachingLogic,
        PackageInfo info, long userSerial, boolean replaceExisting) {
    UserHandle user = cachingLogic.getUser(object);
    ComponentName componentName = cachingLogic.getComponent(object);

    final ComponentKey key = new ComponentKey(componentName, user);
    CacheEntry entry = null;
    if (!replaceExisting) {
        entry = mCache.get(key);
        // We can't reuse the entry if the high-res icon is not present.
        if (entry == null || entry.bitmap.isNullOrLowRes()) {
            entry = null;
        }
    }
    // 新加载图标
    if (entry == null) {
        entry = new CacheEntry();
        entry.bitmap = cachingLogic.loadIcon(mContext, object);
    }

    // 无法从 cachingLogic 加载图标,这意味着已加载替代图标(例如后备图标、默认图标)。
    // 所以我们放在这里,因为缓存空条目没有意义。
    if (entry.bitmap.isNullOrLowRes()) return;
    entry.title = cachingLogic.getLabel(object);
    entry.contentDescription = mPackageManager.getUserBadgedLabel(entry.title, user);
    // 是否需要添加到内存中
    if (cachingLogic.addToMemCache()) mCache.put(key, entry);

    ContentValues values = newContentValues(entry.bitmap, entry.title.toString(),
            componentName.getPackageName(), cachingLogic.getKeywords(object, mLocaleList));
    // 添加到数据库
    addIconToDB(values, componentName, info, userSerial,
            cachingLogic.getLastUpdatedTime(object, info));
}

IconDB

  • 类路径:com.android.launcher3.icons.cache.BaseIconCache.IconDB
  • db数据库名:app_icons.db
  • table表名:icons
某个手机数据库表示例

CachingLogic.java 系列

  • LauncherActivityCachingLogic 用于allApp的图标缓存

  • ShortcutCachingLogic 用于shortcut的图标缓存

  • ComponentWithIconCachingLogic 用于widget的图标缓存

  • 其中 loadIcon 是可以定制图标样式的

public class ShortcutCachingLogic implements CachingLogic<ShortcutInfo> {

    private static final String TAG = "ShortcutCachingLogic";

    // 根据shortcutInfo获取组件
    @Override
    public ComponentName getComponent(ShortcutInfo info) {
        return ShortcutKey.fromInfo(info).componentName;
    }

   ...

    @NonNull
    @Override
    public BitmapInfo loadIcon(Context context, ShortcutInfo info) {
        try (LauncherIcons li = LauncherIcons.obtain(context)) {
            Drawable unbadgedDrawable = ShortcutCachingLogic.getIcon(
                    context, info, LauncherAppState.getIDP(context).fillResIconDpi);
            if (unbadgedDrawable == null) return BitmapInfo.LOW_RES_INFO;
            return new BitmapInfo(li.createScaledBitmapWithoutShadow(
                    unbadgedDrawable, 0), Themes.getColorAccent(context));
        }
    }

    @Override
    public boolean addToMemCache() {
        return false;// 表示不缓存到内存中
    }

    /**
     * Similar to {@link LauncherApps#getShortcutIconDrawable(ShortcutInfo, int)} with additional
     * Launcher specific checks
     */
    public static Drawable getIcon(Context context, ShortcutInfo shortcutInfo, int density) {
        if (GO_DISABLE_WIDGETS) { // 开关控制是否允许有shortcut
            return null;
        }
        try {// 从LauncherApps中查询图标
            return context.getSystemService(LauncherApps.class)
                    .getShortcutIconDrawable(shortcutInfo, density);
        } catch (SecurityException | IllegalStateException e) {
            Log.e(TAG, "Failed to get shortcut icon", e);
            return null;
        }
    }
}

WidgetsModel.java

// True is the widget support is disabled.
public static final boolean GO_DISABLE_WIDGETS = true;
  • 当打开GO_DISABLE_WIDGETS = false ,会开启widget,同时在optionsview上会显示菜单, 如下图
OptionsPopupView.java
public static WidgetsFullSheet openWidgets(Launcher launcher) {
    if (launcher.getPackageManager().isSafeMode()) {
        Toast.makeText(launcher, R.string.safemode_widget_error, Toast.LENGTH_SHORT).show();
        return null;
    } else {
        return WidgetsFullSheet.show(launcher, true /* animated */);
    }
}
  • 异常情况默认图标兜底 makeDefaultIcon
    com.android.launcher3.icons.BaseIconFactory#getFullResDefaultActivityIcon
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,948评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,371评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,490评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,521评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,627评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,842评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,997评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,741评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,203评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,534评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,673评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,339评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,955评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,770评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,000评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,394评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,562评论 2 349

推荐阅读更多精彩内容