Android 應(yīng)用安裝過程源碼解析

本文是 Android 系統(tǒng)學(xué)習(xí)系列文章中的第一章節(jié)的內(nèi)容,介紹了 PackageManagerService 在啟動時如何去加載已安裝的應(yīng)用,通過一個新的應(yīng)用是如何在 PackageManagerService 的幫助下完成安裝過程的。對此系列感興趣的同學(xué),可以收藏這個鏈接 Android 系統(tǒng)學(xué)習(xí),也可以點(diǎn)擊 RSS訂閱 進(jìn)行訂閱。

接下來的分析,如果沒有特別提及,是基于 SDK-23 版本進(jìn)行的。

系統(tǒng)解析安裝包過程

自行解析 Android APK 信息該如何入手呢?從這里入手的話,就得完整地知道系統(tǒng)是如何完成這個過程的,那么自然而然地就能想到通過 PackageManageService 入手進(jìn)行分析,畢竟是掌管所有應(yīng)用的大執(zhí)行官。通過前面 Binder 系列的學(xué)習(xí),我們了解到 PackageManageService 是將自己注入到 SystemManager 中去的,其后其他應(yīng)用就可以通過 SystemServer 來訪問 PackageManageService 了。

在 SystemServer 啟動后,執(zhí)行了 PackageManagerService 的 main 方法,后面的 isFirstBoot 方法是避免 PackageManangerService 被重復(fù)啟動。

// Start the package manager.
Slog.i(TAG, "Package Manager");
mPackageManagerService = PackageManagerService.main(mSystemContext, installer,
        mFactoryTestMode != FactoryTest.FACTORY_TEST_OFF, mOnlyCore);
mFirstBoot = mPackageManagerService.isFirstBoot();
mPackageManager = mSystemContext.getPackageManager();

main 方法中,調(diào)用了構(gòu)造函數(shù),并將自己注入到 SystemServer 去,看起來大部分邏輯都在構(gòu)造函數(shù)里,接著往下分析。

public static PackageManagerService main(Context context, Installer installer,
        boolean factoryTest, boolean onlyCore) {
    PackageManagerService m = new PackageManagerService(context, installer,
            factoryTest, onlyCore);
    ServiceManager.addService("package", m);
    return m;
}

構(gòu)造函數(shù)相當(dāng)復(fù)雜,這里只說明,對我們分析有用的東西。


// ... other code

// Collect vendor overlay packages.
// (Do this before scanning any apps.)
// For security and version matching reason, only consider
// overlay packages if they reside in VENDOR_OVERLAY_DIR.
File vendorOverlayDir = new File(VENDOR_OVERLAY_DIR);
scanDirLI(vendorOverlayDir, PackageParser.PARSE_IS_SYSTEM
        | PackageParser.PARSE_IS_SYSTEM_DIR, scanFlags | SCAN_TRUSTED_OVERLAY, 0);

// Find base frameworks (resource packages without code).
scanDirLI(frameworkDir, PackageParser.PARSE_IS_SYSTEM
        | PackageParser.PARSE_IS_SYSTEM_DIR
        | PackageParser.PARSE_IS_PRIVILEGED,
        scanFlags | SCAN_NO_DEX, 0);

// Collected privileged system packages.
final File privilegedAppDir = new File(Environment.getRootDirectory(), "priv-app");
scanDirLI(privilegedAppDir, PackageParser.PARSE_IS_SYSTEM
        | PackageParser.PARSE_IS_SYSTEM_DIR
        | PackageParser.PARSE_IS_PRIVILEGED, scanFlags, 0);

// Collect ordinary system packages.
final File systemAppDir = new File(Environment.getRootDirectory(), "app");
scanDirLI(systemAppDir, PackageParser.PARSE_IS_SYSTEM
        | PackageParser.PARSE_IS_SYSTEM_DIR, scanFlags, 0);

// Collect all vendor packages.
File vendorAppDir = new File("/vendor/app");
try {
    vendorAppDir = vendorAppDir.getCanonicalFile();
} catch (IOException e) {
    // failed to look up canonical path, continue with original one
}
scanDirLI(vendorAppDir, PackageParser.PARSE_IS_SYSTEM
        | PackageParser.PARSE_IS_SYSTEM_DIR, scanFlags, 0);

// Collect all OEM packages.
final File oemAppDir = new File(Environment.getOemDirectory(), "app");
scanDirLI(oemAppDir, PackageParser.PARSE_IS_SYSTEM
        | PackageParser.PARSE_IS_SYSTEM_DIR, scanFlags, 0);

// ... other code

如上代碼,可以看到 PackageManagerService 在構(gòu)造函數(shù)的時候,會掃描對應(yīng)目錄下的 APK,保證這些 APK 的信息被預(yù)先加載進(jìn)來。我在 root 的手機(jī)下,在 /system/priv-app 目錄下截圖,這些就是系統(tǒng)高優(yōu)先級加載的系統(tǒng)應(yīng)用。

priv-app-tele

接下來看看 scanDirLI 這個函數(shù),看看這里面進(jìn)行了什么勾當(dāng)。

private void scanDirLI(File dir, int parseFlags, int scanFlags, long currentTime) {
    final File[] files = dir.listFiles();

    // ...

    for (File file : files) {
        final boolean isPackage = (isApkFile(file) || file.isDirectory())
                && !PackageInstallerService.isStageName(file.getName());
        if (!isPackage) {
            // Ignore entries which are not packages
            continue;
        }
        try {
            scanPackageLI(file, parseFlags | PackageParser.PARSE_MUST_BE_APK,
                    scanFlags, currentTime, null);
        } catch (PackageManagerException e) {
            // ...
        }
    }
}

看起來,這里面只是做了對 APK 文件的過濾,并沒有實(shí)際對于 APK 解析的代碼,那么接著看看 scanPackageLI 的實(shí)現(xiàn)。

private PackageParser.Package scanPackageLI(File scanFile, int parseFlags, int scanFlags,
        long currentTime, UserHandle user) throws PackageManagerException {

    // ...

    final PackageParser.Package pkg;
    try {
        pkg = pp.parsePackage(scanFile, parseFlags);
    } catch (PackageParserException e) {
        throw PackageManagerException.from(e);
    }

    // ...

}

scanPackageLI 的實(shí)現(xiàn)也相對復(fù)雜,比如設(shè)計對供應(yīng)商內(nèi)置 APP 的更新邏輯等等,這里只關(guān)心是如何解析 APK 的,那么看看 parsePackage 是怎么實(shí)現(xiàn)的,我們能否通過 parsePackage 來達(dá)到我們自動解析 APK 的目的了?

parsePackage 方法,在不同 SDK 的版本里面實(shí)現(xiàn)各不一樣,有興趣的讀者可以從這個鏈接 http://grepcode.com/search?query=packageParser 里面查看各個版本的實(shí)現(xiàn),這里只看 SDK 23 版本的實(shí)現(xiàn),這里針對 APK 目錄和單一的 APK 文件分別進(jìn)行處理。

public Package parsePackage(File packageFile, int flags) throws PackageParserException {
    if (packageFile.isDirectory()) {
        return parseClusterPackage(packageFile, flags);
    } else {
        return parseMonolithicPackage(packageFile, flags);
    }
}

接著看看 parseMonolithicPackage 的實(shí)現(xiàn),前面的 mOnlyCoreApps 參數(shù)是對 Core APP 進(jìn)行的處理,這邊可以不看,主要看 parseBaseApk 方法的實(shí)現(xiàn)。

public Package parseMonolithicPackage(File apkFile, int flags) throws PackageParserException {
    if (mOnlyCoreApps) {
        final PackageLite lite = parseMonolithicPackageLite(apkFile, flags);
        if (!lite.coreApp) {
            throw new PackageParserException(INSTALL_PARSE_FAILED_MANIFEST_MALFORMED,
                    "Not a coreApp: " + apkFile);
        }
    }

    final AssetManager assets = new AssetManager();
    try {
        final Package pkg = parseBaseApk(apkFile, assets, flags);
        pkg.codePath = apkFile.getAbsolutePath();
        return pkg;
    } finally {
        IoUtils.closeQuietly(assets);
    }
}

在 parseBaseApk 中開始根據(jù)各個具體的節(jié)點(diǎn)(如 Application、Activity 等等),進(jìn)行解析,最后得到整個 Package 的信息。

private Package parseBaseApk(Resources res, XmlResourceParser parser, int flags,
        String[] outError) throws XmlPullParserException, IOException {

        // ...

        if (tagName.equals("application")) {
            if (!parseBaseApplication(pkg, res, parser, attrs, flags, outError)) {
                return null;
            }
        } else if (tagName.equals("overlay")) {
          // ...
        } else if (tagName.equals("permission")) {
            if (parsePermission(pkg, res, parser, attrs, outError) == null) {
                return null;
            }
        }

        // ...
        return pkg;
}

這些得到的各種信息,最后都會存儲在 PackageManagerService 中,這些信息就此保留下來,可用用來響應(yīng)各種 IntentFilter ,等待啟動命令。

經(jīng)過上面的代碼分析,可以看出,如果想要獲取相應(yīng)的包信息,可以調(diào)用 Package.parsePackage 方法來進(jìn)行解析,這里唯一需要注意的地方在于這個方法的簽名在不同 SDK 版本是不同的,需要針對不同版本做處理。

APK 包安裝過程

我們知道怎么獲取包信息了,但這還不夠,我們在安裝應(yīng)用的時候,還彈出了一個安裝界面,這個安裝界面背后有什么邏輯了?這些邏輯應(yīng)該也對我們實(shí)現(xiàn)加載插件很有幫助吧,我們來仔細(xì)地分析下。

實(shí)際在處理安裝應(yīng)用 Intent 的是 PackageInstallerActivity,但這個類廠商可以隨意修改,這個類也并沒有在 android.jar 中,這里就不做分析了。PackageInstallerActivity 在安裝過程中,實(shí)際調(diào)用的是 ApplicationPackageManager 里面的代碼。

private void installCommon(Uri packageURI,
        PackageInstallObserver observer, int flags, String installerPackageName,
        VerificationParams verificationParams, ContainerEncryptionParams encryptionParams) {
    if (!"file".equals(packageURI.getScheme())) {
        throw new UnsupportedOperationException("Only file:// URIs are supported");
    }
    if (encryptionParams != null) {
        throw new UnsupportedOperationException("ContainerEncryptionParams not supported");
    }

    final String originPath = packageURI.getPath();
    try {
        mPM.installPackage(originPath, observer.getBinder(), flags, installerPackageName,
                verificationParams, null);
    } catch (RemoteException ignored) {
    }
}

在 ApplicationPackageManager 中是通過 Binder 機(jī)制調(diào)用了 PackageManagerService 中的 installPackage 方法,讓我們一探究竟。

@Override
public void installPackage(String originPath, IPackageInstallObserver2 observer,
        int installFlags, String installerPackageName, VerificationParams verificationParams,
        String packageAbiOverride) {
    installPackageAsUser(originPath, observer, installFlags, installerPackageName,
            verificationParams, packageAbiOverride, UserHandle.getCallingUserId());
}

接著調(diào)用了 installPackageAsUser 方法。

public void installPackageAsUser(String originPath, IPackageInstallObserver2 observer,
        int installFlags, String installerPackageName, VerificationParams verificationParams,
        String packageAbiOverride, int userId) {
          final File originFile = new File(originPath);

    // 權(quán)限校驗

    final OriginInfo origin = OriginInfo.fromUntrustedFile(originFile);

    final Message msg = mHandler.obtainMessage(INIT_COPY);
    msg.obj = new InstallParams(origin, null, observer, installFlags, installerPackageName,
            null, verificationParams, user, packageAbiOverride, null);
    mHandler.sendMessage(msg);
}

原來是通過 Handler 方式發(fā)送消息的,那么看看 PackageHandler 是如何處理這個消息的。在接受到 INIT_COPY 消息后,將要安裝的參數(shù)信息加入到 PendingInstalls 中去,如果是第一個安裝,還需要發(fā)送 MCS_BOUND 消息,用于觸發(fā)實(shí)際安裝過程。

case INIT_COPY: {
    HandlerParams params = (HandlerParams) msg.obj;
    int idx = mPendingInstalls.size();
    if (DEBUG_INSTALL) Slog.i(TAG, "init_copy idx=" + idx + ": " + params);
    // If a bind was already initiated we dont really
    // need to do anything. The pending install
    // will be processed later on.
    if (!mBound) {
        // If this is the only one pending we might
        // have to bind to the service again.
        if (!connectToService()) {
            Slog.e(TAG, "Failed to bind to media container service");
            params.serviceError();
            return;
        } else {
            // Once we bind to the service, the first
            // pending request will be processed.
            mPendingInstalls.add(idx, params);
        }
    } else {
        mPendingInstalls.add(idx, params);
        // Already bound to the service. Just make
        // sure we trigger off processing the first request.
        if (idx == 0) {
            mHandler.sendEmptyMessage(MCS_BOUND);
        }
    }
    break;
}

在 MCS_BOUND 消息中取出第一個安裝請求,并調(diào)用 startCopy 方法。

HandlerParams params = mPendingInstalls.get(0);
if (params != null) {
   if (params.startCopy()) {
     // ...
   }
   // ...
}

在 startCopy 中調(diào)用 handleStartCopy 方法,由于這個類,需要與 MCS (MediaContainerService) 進(jìn)行通信,有可能發(fā)生異常,因而這里設(shè)置了重試機(jī)制。

if (++mRetries > MAX_RETRIES) {
    Slog.w(TAG, "Failed to invoke remote methods on default container service. Giving up");
    mHandler.sendEmptyMessage(MCS_GIVE_UP);
    handleServiceError();
    return false;
} else {
    handleStartCopy();
    res = true;
}
handleReturnCode();

接著看看 handleStartCopy 里面是如何進(jìn)行的,為啥還有可能失敗?

public void handleStartCopy() throws RemoteException {

  // 安裝在哪里?
  final boolean onSd = (installFlags & PackageManager.INSTALL_EXTERNAL) != 0;
  final boolean onInt = (installFlags & PackageManager.INSTALL_INTERNAL) != 0;

  pkgLite = mContainerService.getMinimalPackageInfo(origin.resolvedPath, installFlags,
          packageAbiOverride);

  // 如果安裝空間不夠了,嘗試釋放一些空間
  // 校驗等邏輯

  final InstallArgs args = createInstallArgs(this);
  // ...
  args.copyApk();

}

private InstallArgs createInstallArgs(InstallParams params) {
    if (params.move != null) {
        return new MoveInstallArgs(params);
    } else if (installOnExternalAsec(params.installFlags) || params.isForwardLocked()) {
        return new AsecInstallArgs(params);
    } else {
        return new FileInstallArgs(params);
    }
}

看起來失敗的可能性,大多來自于 Media 服務(wù),空間不足,當(dāng)時不能寫等等都可能導(dǎo)致失敗。在空間判斷、校驗通過后,根據(jù)不同的情況創(chuàng)建不同的 InstallArgs,這里只看 FileInstallArgs。copyApk 的主要任務(wù)是拷貝 APK 文件和對應(yīng)的 lib 文件到 /data/app/{packageName} 目錄下。

data_app_package.png
int copyApk(IMediaContainerService imcs, boolean temp) throws RemoteException {
  // ...
  final IParcelFileDescriptorFactory target = new IParcelFileDescriptorFactory.Stub() {
    @Override
    public ParcelFileDescriptor open(String name, int mode) throws RemoteException {
        if (!FileUtils.isValidExtFilename(name)) {
            throw new IllegalArgumentException("Invalid filename: " + name);
        }
        try {
            final File file = new File(codeFile, name);
            final FileDescriptor fd = Os.open(file.getAbsolutePath(),
                    O_RDWR | O_CREAT, 0644);
            Os.chmod(file.getAbsolutePath(), 0644);
            return new ParcelFileDescriptor(fd);
        } catch (ErrnoException e) {
            throw new RemoteException("Failed to open: " + e.getMessage());
        }
    }
  };
  ret = imcs.copyPackage(origin.file.getAbsolutePath(), target);
  // ...
  final File libraryRoot = new File(codeFile, LIB_DIR_NAME);
  NativeLibraryHelper.Handle handle = null;
  try {
      handle = NativeLibraryHelper.Handle.create(codeFile);
      ret = NativeLibraryHelper.copyNativeBinariesWithOverride(handle, libraryRoot,
              abiOverride);
  } catch (IOException e) {
      Slog.e(TAG, "Copying native libraries failed", e);
      ret = PackageManager.INSTALL_FAILED_INTERNAL_ERROR;
  } finally {
      IoUtils.closeQuietly(handle);
  }
}

在 handleStartCopy 后,就可以繼續(xù)執(zhí)行 handleReturnCode 后的代碼。這部分會不會和前面 PackageManageService 在啟動時候掃描系統(tǒng) APK 的邏輯相同了?讓我們拭目以待。

@Override
void handleReturnCode() {
    // If mArgs is null, then MCS couldn't be reached. When it
    // reconnects, it will try again to install. At that point, this
    // will succeed.
    if (mArgs != null) {
        processPendingInstall(mArgs, mRet);
    }
}

接著看 processPendingInstall 的實(shí)現(xiàn)。

if (res.returnCode == PackageManager.INSTALL_SUCCEEDED) {
    args.doPreInstall(res.returnCode);
    synchronized (mInstallLock) {
        installPackageLI(args, res);
    }
    args.doPostInstall(res.returnCode, res.uid);
}

installPackageLI 中會一些列復(fù)雜的邏輯,這里只看針對新安裝包的邏輯。

if (replace) {
    replacePackageLI(pkg, parseFlags, scanFlags | SCAN_REPLACING, args.user,
            installerPackageName, volumeUuid, res);
} else {
    installNewPackageLI(pkg, parseFlags, scanFlags | SCAN_DELETE_DATA_ON_FAILURES,
            args.user, installerPackageName, volumeUuid, res);
}

在 installNewPackageLI 中會繼續(xù)調(diào)用 scanPackageLI 方法,這和第一章節(jié)講述的一直,這里就不再贅述了。

try {
    PackageParser.Package newPackage = scanPackageLI(pkg, parseFlags, scanFlags,
            System.currentTimeMillis(), user);

    updateSettingsLI(newPackage, installerPackageName, volumeUuid, null, null, res, user);
    // delete the partially installed application. the data directory will have to be
    // restored if it was already existing
    if (res.returnCode != PackageManager.INSTALL_SUCCEEDED) {
        // remove package from internal structures.  Note that we want deletePackageX to
        // delete the package data and cache directories that it created in
        // scanPackageLocked, unless those directories existed before we even tried to
        // install.
        deletePackageLI(pkgName, UserHandle.ALL, false, null, null,
                dataDirExists ? PackageManager.DELETE_KEEP_DATA : 0,
                        res.removedInfo, true);
    }

} catch (PackageManagerException e) {
    res.setError("Package couldn't be installed in " + pkg.codePath, e);
}

可能讀者會想安裝包的資源怎么處理的?總不會只處理 lib 文件和 dex 文件吧,這是前面一個疏漏的地方,在文末進(jìn)行下簡單的補(bǔ)充。

在 Packageparser 進(jìn)行解析的時候,會通過 AssetMananger 進(jìn)行資源的加載。

XmlResourceParser parser = null;
AssetManager assmgr = null;
Resources res = null;
boolean assetError = true;
try {
  assmgr = new AssetManager();
  int cookie = assmgr.addAssetPath(mArchiveSourcePath);
  if (cookie != 0) {
    res = new Resources(assmgr, metrics, null);
    assmgr.setConfiguration(0, 0, null, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        Build.VERSION.RESOURCES_SDK_INT);
    parser = assmgr.openXmlResourceParser(cookie, ANDROID_MANIFEST_FILENAME);
    assetError = false;
  } else {
    VLog.w(TAG, "Failed adding asset path:" + mArchiveSourcePath);
  }
} catch (Exception e) {
  VLog.w(TAG, "Unable to read AndroidManifest.xml of " + mArchiveSourcePath, e);
}

文檔信息


最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,908評論 6 541
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,324評論 3 429
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,018評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,675評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 72,417評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,783評論 1 329
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,779評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,960評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,522評論 1 335
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 41,267評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 43,471評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,009評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,698評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,099評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,386評論 1 294
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,204評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 48,436評論 2 378

推薦閱讀更多精彩內(nèi)容