動態加載技術(插件化)系列已經坑了有一段時間了,不過UP主我並無放棄治療哈,相信在不就的將來就能夠看到「系統Api Hook模式」和插件化框架Frontia的更新了。今天要講的是動態加載技術的親戚 —— MultiDex。他們的核心原理之一都是dex文件的加載。html
MultiDex是Google爲了解決「65535方法數超標」以及「INSTALL_FAILED_DEXOPT」問題而開發的一個Support庫,具體如何使用MultiDex如今市面已經有一大堆教程(能夠參考給 App 啓用 MultiDex 功能),這裏再也不贅述。這篇日誌主要是配合源碼分析MultiDex的工做原理,以及提供一些MultiDex優化的方案。java
等等,這個章節講的不是MultiDex嗎,怎麼變成Dex了?沒錯哈,沒有Dex,哪來的MultiDex。在Android中,對Dex文件操做對應的類叫作DexFile。在CLASSLOADER 的工做機制中,咱們說到:android
對於 Java 程序來講,編寫程序就是編寫類,運行程序也就是運行類(編譯獲得的class文件),其中起到關鍵做用的就是類加載器 ClassLoader。編程
Android程序的每個Class都是由ClassLoader#loadClass方法加載進內存的,更準確來講,一個ClassLoader實例會有一個或者多個DexFile實例,調用了ClassLoader#loadClass以後,ClassLoader會經過類名,在本身的DexFile數組裏面查找有沒有那個DexFile對象裏面存在這個類,若是都沒有就拋ClassNotFound異常。ClassLoader經過調用DexFile的一個叫defineClass的Native方法去加載指定的類,這點與JVM略有不一樣,後者是直接調用ClassLoader#defineCLass方法,反正最後實際加載類的方法都叫defineClass就沒錯了?。數組
首先來看看造DexFile對象的構方法。緩存
public final class DexFile { private int mCookie; private final String mFileName; ... public DexFile(File file) throws IOException { this(file.getPath()); } public DexFile(String fileName) throws IOException { mCookie = openDexFile(fileName, null, 0); mFileName = fileName; guard.open("close"); } private DexFile(String sourceName, String outputName, int flags) throws IOException { mCookie = openDexFile(sourceName, outputName, flags); mFileName = sourceName; guard.open("close"); } static public DexFile loadDex(String sourcePathName, String outputPathName, int flags) throws IOException { return new DexFile(sourcePathName, outputPathName, flags); } public Class loadClass(String name, ClassLoader loader) { String slashName = name.replace('.', '/'); return loadClassBinaryName(slashName, loader); } public Class loadClassBinaryName(String name, ClassLoader loader) { return defineClass(name, loader, mCookie); } private native static Class defineClass(String name, ClassLoader loader, int cookie); native private static int openDexFile(String sourceName, String outputName, int flags) throws IOException; native private static int openDexFile(byte[] fileContents) ... }
經過之前分析過的源碼,咱們知道ClassLoader主要是經過DexFile.loadDex這個靜態方法來建立它須要的DexFile實例的,這裏建立DexFile的時候,保存了Dex文件的文件路徑mFileName,同時調用了openDexFile的Native方法打開Dex文件並返回了一個mCookie的整型變量(我不知道這個幹啥用的,我猜它是一個C++用的資源句柄,用於Native層訪問具體的Dex文件)。在Native層的openDexFile方法裏,主要作了檢查當前建立來的Dex文件是不是有效的Dex文件,仍是是一個帶有Dex文件的壓縮包,仍是一個無效的Dex文件。安全
加載類的時候,ClassLoader又是經過DexFile#loadClass這個方法來完成的,這個方法裏調用了defineClass這個Native方法,看來DexFile纔是加載Class的具體API,加載Dex文件和加載具體Class都是經過Native方法完成,ClassLoader有點名存實亡啊。cookie
當一個Dex文件太肥的時候(方法數目太多、文件太大),在打包Apk文件的時候就會出問題,就算打包的時候不出問題,在Android 5.0如下設備上安裝或運行Apk也會出問題(具體緣由能夠參考給 App 啓用 MultiDex 功能)。既然一個Dex文件不行的話,那就把這個碩大的Dex文件拆分紅若干個小的Dex文件,恰好一個ClassLoader能夠有多個DexFile,這就是MultiDex的基本設計思路。app
MultiDex的工做流程具體分爲兩個部分,一個部分是打包構建Apk的時候,將Dex文件拆分紅若干個小的Dex文件,這個Android Studio已經幫咱們作了(設置 「multiDexEnabled true」),另外一部分就是在啓動Apk的時候,同時加載多個Dex文件(具體是加載Dex文件優化後的Odex文件,不過文件名仍是.dex),這一部分工做從Android 5.0開始系統已經幫咱們作了,可是在Android 5.0之前仍是須要經過MultiDex Support庫來支持(MultiDex.install(Context))。框架
因此咱們須要關心的是第二部分,這個過程的簡單示意流程圖以下。
(圖中紅色部分爲耗時比較大的地方)
如今官方已經部署的MultiDex Support版本是com.android.support:multidex:1.0.1
,可是如今倉庫的master分支已經有了許多新的提交(其中最明顯的區別是加入了FileLock來控制多進程同步問題),因此這裏分析的源碼都是最新的master分支上的。
MultiDex Support的入口是MultiDex.install(Context)
,先從這裏入手吧。(此次我把具體的分析都寫在代碼的註釋了,這樣看是否是更簡潔明瞭些?)
public static void install(Context context) { Log.i(TAG, "install"); // 1. 判讀是否須要執行MultiDex。 if (IS_VM_MULTIDEX_CAPABLE) { Log.i(TAG, "VM has multidex support, MultiDex support library is disabled."); return; } if (Build.VERSION.SDK_INT < MIN_SDK_VERSION) { throw new RuntimeException("Multi dex installation failed. SDK " + Build.VERSION.SDK_INT + " is unsupported. Min SDK version is " + MIN_SDK_VERSION + "."); } try { ApplicationInfo applicationInfo = getApplicationInfo(context); if (applicationInfo == null) { // Looks like running on a test Context, so just return without patching. return; } // 2. 若是這個方法已經調用過一次,就不能再調用了。 synchronized (installedApk) { String apkPath = applicationInfo.sourceDir; if (installedApk.contains(apkPath)) { return; } installedApk.add(apkPath); // 3. 若是當前Android版本已經自身支持了MultiDex,依然能夠執行MultiDex操做, // 可是會有警告。 if (Build.VERSION.SDK_INT > MAX_SUPPORTED_SDK_VERSION) { Log.w(TAG, "MultiDex is not guaranteed to work in SDK version " + Build.VERSION.SDK_INT + ": SDK version higher than " + MAX_SUPPORTED_SDK_VERSION + " should be backed by " + "runtime with built-in multidex capabilty but it's not the " + "case here: java.vm.version=\"" + System.getProperty("java.vm.version") + "\""); } // 4. 獲取當前的ClassLoader實例,後面要作的工做,就是把其餘dex文件加載後, // 把其DexFile對象添加到這個ClassLoader實例裏就完事了。 ClassLoader loader; try { loader = context.getClassLoader(); } catch (RuntimeException e) { Log.w(TAG, "Failure while trying to obtain Context class loader. " + "Must be running in test mode. Skip patching.", e); return; } if (loader == null) { Log.e(TAG, "Context class loader is null. Must be running in test mode. " + "Skip patching."); return; } try { // 5. 清除舊的dex文件,注意這裏不是清除上次加載的dex文件緩存。 // 獲取dex緩存目錄是,會優先獲取/data/data/<package>/code-cache做爲緩存目錄。 // 若是獲取失敗,則使用/data/data/<package>/files/code-cache目錄。 // 這裏清除的是第二個目錄。 clearOldDexDir(context); } catch (Throwable t) { Log.w(TAG, "Something went wrong when trying to clear old MultiDex extraction, " + "continuing without cleaning.", t); } // 6. 獲取緩存目錄(/data/data/<package>/code-cache)。 File dexDir = getDexDir(context, applicationInfo); // 7. 加載緩存文件(若是有)。 List<File> files = MultiDexExtractor.load(context, applicationInfo, dexDir, false); // 8. 檢查緩存的dex是否安全 if (checkValidZipFiles(files)) { // 9. 安裝緩存的dex installSecondaryDexes(loader, dexDir, files); } else { // 9. 從apk壓縮包裏面提取dex文件 Log.w(TAG, "Files were not valid zip files. Forcing a reload."); files = MultiDexExtractor.load(context, applicationInfo, dexDir, true); if (checkValidZipFiles(files)) { // 10. 安裝提取的dex installSecondaryDexes(loader, dexDir, files); } else { throw new RuntimeException("Zip files were not valid."); } } } } catch (Exception e) { Log.e(TAG, "Multidex installation failure", e); throw new RuntimeException("Multi dex installation failed (" + e.getMessage() + ")."); } Log.i(TAG, "install done"); }
具體代碼的分析已經在上面代碼的註釋裏給出了,從這裏咱們也能夠看出,整個MultiDex.install(Context)的過程當中,關鍵的步驟就是MultiDexExtractor#load
方法和MultiDex#installSecondaryDexes
方法。
(這部分是題外話)其中有個MultiDex#clearOldDexDir(Context)方法,這個方法的做用是刪除/data/data/<package>/files/code-cache,一開始我覺得這個方法是刪除上一次執行MultiDex後的緩存文件,不過這明顯不對,不可能每次MultiDex都從新解壓dex文件一邊,這樣每次啓動會很耗時,只有第一次冷啓動的時候才須要解壓dex文件。後來我又想是否是之前舊版的MultiDex曾經把緩存文件放在這個目錄裏,如今新版本只是清除之前舊版的遺留文件?可是我找遍了整個MultiDex Repo的提交也沒有見過相似的舊版本代碼。後面我仔細看MultiDex#getDexDir這個方法才發現,原來MultiDex在獲取dex緩存目錄是,會優先獲取/data/data/<package>/code-cache做爲緩存目錄,若是獲取失敗,則使用/data/data/<package>/files/code-cache目錄,然後者的緩存文件會在每次App從新啓動的時候被清除。感受MultiDex獲取緩存目錄的邏輯不是很嚴謹,而獲取緩存目錄失敗也是MultiDex工做工程中少數有重試機制的地方,看來MultiDex真的是一個臨時的兼容方案,Google也許並不打算認真處理這些歷史的黑鍋。
接下來再看看MultiDexExtractor#load這個方法。
static List<File> load(Context context, ApplicationInfo applicationInfo, File dexDir, boolean forceReload) throws IOException { Log.i(TAG, "MultiDexExtractor.load(" + applicationInfo.sourceDir + ", " + forceReload + ")"); final File sourceApk = new File(applicationInfo.sourceDir); // 1. 獲取當前Apk文件的crc值。 long currentCrc = getZipCrc(sourceApk); // Validity check and extraction must be done only while the lock file has been taken. File lockFile = new File(dexDir, LOCK_FILENAME); RandomAccessFile lockRaf = new RandomAccessFile(lockFile, "rw"); FileChannel lockChannel = null; FileLock cacheLock = null; List<File> files; IOException releaseLockException = null; try { lockChannel = lockRaf.getChannel(); Log.i(TAG, "Blocking on lock " + lockFile.getPath()); // 2. 加上文件鎖,防止多進程衝突。 cacheLock = lockChannel.lock(); Log.i(TAG, lockFile.getPath() + " locked"); // 3. 先判斷是否強制從新解壓,這裏第一次會優先使用已解壓過的dex文件,若是加載失敗就強制從新解壓。 // 此外,經過crc和文件修改時間,判斷若是Apk文件已經被修改(覆蓋安裝),就會跳過緩存從新解壓dex文件。 if (!forceReload && !isModified(context, sourceApk, currentCrc)) { try { // 4. 加載緩存的dex文件 files = loadExistingExtractions(context, sourceApk, dexDir); } catch (IOException ioe) { Log.w(TAG, "Failed to reload existing extracted secondary dex files," + " falling back to fresh extraction", ioe); // 5. 加載失敗的話從新解壓,並保存解壓出來的dex文件的信息。 files = performExtractions(sourceApk, dexDir); putStoredApkInfo(context, getTimeStamp(sourceApk), currentCrc, files.size() + 1); } } else { // 4. 從新解壓,並保存解壓出來的dex文件的信息。 Log.i(TAG, "Detected that extraction must be performed."); files = performExtractions(sourceApk, dexDir); putStoredApkInfo(context, getTimeStamp(sourceApk), currentCrc, files.size() + 1); } } finally { if (cacheLock != null) { try { cacheLock.release(); } catch (IOException e) { Log.e(TAG, "Failed to release lock on " + lockFile.getPath()); // Exception while releasing the lock is bad, we want to report it, but not at // the price of overriding any already pending exception. releaseLockException = e; } } if (lockChannel != null) { closeQuietly(lockChannel); } closeQuietly(lockRaf); } if (releaseLockException != null) { throw releaseLockException; } Log.i(TAG, "load found " + files.size() + " secondary dex files"); return files; }
這個過程主要是獲取能夠安裝的dex文件列表,能夠是上次解壓出來的緩存文件,也能夠是從新從Apk包裏面提取出來的。須要注意的時,若是是從新解壓,這裏會有明顯的耗時,並且解壓出來的dex文件,會被壓縮成.zip壓縮包,壓縮的過程也會有明顯的耗時(這裏壓縮dex文件多是問了節省空間)。
若是dex文件是從新解壓出來的,則會保存dex文件的信息,包括解壓的apk文件的crc值、修改時間以及dex文件的數目,以便下一次啓動直接使用已經解壓過的dex緩存文件,而不是每一次都從新解壓。
須要特別提到的是,裏面的FileLock是最新的master分支裏面新加進去的功能,如今最新的1.0.1
版本里面是沒有的。
不管是經過使用緩存的dex文件,仍是從新從apk中解壓dex文件,獲取dex文件列表後,下一步就是安裝(或者說加載)這些dex文件了。最後的工做在MultiDex#installSecondaryDexes
這個方法裏面。
private static void installSecondaryDexes(ClassLoader loader, File dexDir, List<File> files) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException, IOException { if (!files.isEmpty()) { if (Build.VERSION.SDK_INT >= 19) { V19.install(loader, files, dexDir); } else if (Build.VERSION.SDK_INT >= 14) { V14.install(loader, files, dexDir); } else { V4.install(loader, files); } } }
由於在不一樣的SDK版本上,ClassLoader(更準確來講是DexClassLoader)加載dex文件的方式有所不一樣,因此這裏作了V4/V14/V19的兼容(Magic Code)。
Build.VERSION.SDK_INT < 14
/** * Installer for platform versions 4 to 13. */ private static final class V4 { private static void install(ClassLoader loader, List<File> additionalClassPathEntries) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, IOException { int extraSize = additionalClassPathEntries.size(); Field pathField = findField(loader, "path"); StringBuilder path = new StringBuilder((String) pathField.get(loader)); String[] extraPaths = new String[extraSize]; File[] extraFiles = new File[extraSize]; ZipFile[] extraZips = new ZipFile[extraSize]; DexFile[] extraDexs = new DexFile[extraSize]; for (ListIterator<File> iterator = additionalClassPathEntries.listIterator(); iterator.hasNext();) { File additionalEntry = iterator.next(); String entryPath = additionalEntry.getAbsolutePath(); path.append(':').append(entryPath); int index = iterator.previousIndex(); extraPaths[index] = entryPath; extraFiles[index] = additionalEntry; extraZips[index] = new ZipFile(additionalEntry); extraDexs[index] = DexFile.loadDex(entryPath, entryPath + ".dex", 0); } // 這個版本是最簡單的。 // 只須要建立DexFile對象後,使用反射的方法分別擴展ClassLoader實例的如下字段便可。 pathField.set(loader, path.toString()); expandFieldArray(loader, "mPaths", extraPaths); expandFieldArray(loader, "mFiles", extraFiles); expandFieldArray(loader, "mZips", extraZips); expandFieldArray(loader, "mDexs", extraDexs); } }
14 <= Build.VERSION.SDK_INT < 19
/** * Installer for platform versions 14, 15, 16, 17 and 18. */ private static final class V14 { private static void install(ClassLoader loader, List<File> additionalClassPathEntries, File optimizedDirectory) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException { // 擴展ClassLoader實例的"pathList"字段。 Field pathListField = findField(loader, "pathList"); Object dexPathList = pathListField.get(loader); expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList, new ArrayList<File>(additionalClassPathEntries), optimizedDirectory)); } private static Object[] makeDexElements( Object dexPathList, ArrayList<File> files, File optimizedDirectory) throws IllegalAccessException, InvocationTargetException, NoSuchMethodException { Method makeDexElements = findMethod(dexPathList, "makeDexElements", ArrayList.class, File.class); return (Object[]) makeDexElements.invoke(dexPathList, files, optimizedDirectory); } }
從API14開始,DexClassLoader會使用一個DexpDexPathList類來封裝DexFile數組。
final class DexPathList { private static final String DEX_SUFFIX = ".dex"; private static final String JAR_SUFFIX = ".jar"; private static final String ZIP_SUFFIX = ".zip"; private static final String APK_SUFFIX = ".apk"; private static Element[] makeDexElements(ArrayList<File> files, File optimizedDirectory) { ArrayList<Element> elements = new ArrayList<Element>(); for (File file : files) { ZipFile zip = null; DexFile dex = null; String name = file.getName(); if (name.endsWith(DEX_SUFFIX)) { // Raw dex file (not inside a zip/jar). try { dex = loadDexFile(file, optimizedDirectory); } catch (IOException ex) { System.logE("Unable to load dex file: " + file, ex); } } else if (name.endsWith(APK_SUFFIX) || name.endsWith(JAR_SUFFIX) || name.endsWith(ZIP_SUFFIX)) { try { zip = new ZipFile(file); } catch (IOException ex) { System.logE("Unable to open zip file: " + file, ex); } try { dex = loadDexFile(file, optimizedDirectory); } catch (IOException ignored) { } } else { System.logW("Unknown file type for: " + file); } if ((zip != null) || (dex != null)) { elements.add(new Element(file, zip, dex)); } } return elements.toArray(new Element[elements.size()]); } private static DexFile loadDexFile(File file, File optimizedDirectory) throws IOException { if (optimizedDirectory == null) { return new DexFile(file); } else { String optimizedPath = optimizedPathFor(file, optimizedDirectory); return DexFile.loadDex(file.getPath(), optimizedPath, 0); } } }
經過調用DexPathList#makeDexElements方法,能夠加載咱們上面解壓獲得的dex文件,從代碼也能夠看出,DexPathList#makeDexElements其實也是經過調用DexFile#loadDex來加載dex文件並建立DexFile對象的。V14中,經過反射調用DexPathList#makeDexElements方法加載咱們須要的dex文件,在把加載獲得的數組擴展到ClassLoader實例的"pathList"字段,從而完成dex文件的安裝。
從DexPathList的代碼中咱們也能夠看出,ClassLoader是支持直接加載.dex/.zip/.jar/.apk的dex文件包的(我記得之前在哪篇日誌中好像提到過相似的問題…)。
19 <= Build.VERSION.SDK_INT
/** * Installer for platform versions 19. */ private static final class V19 { private static void install(ClassLoader loader, List<File> additionalClassPathEntries, File optimizedDirectory) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException { Field pathListField = findField(loader, "pathList"); Object dexPathList = pathListField.get(loader); ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>(); expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList, new ArrayList<File>(additionalClassPathEntries), optimizedDirectory, suppressedExceptions)); if (suppressedExceptions.size() > 0) { for (IOException e : suppressedExceptions) { Log.w(TAG, "Exception in makeDexElement", e); } Field suppressedExceptionsField = findField(dexPathList, "dexElementsSuppressedExceptions"); IOException[] dexElementsSuppressedExceptions = (IOException[]) suppressedExceptionsField.get(dexPathList); if (dexElementsSuppressedExceptions == null) { dexElementsSuppressedExceptions = suppressedExceptions.toArray( new IOException[suppressedExceptions.size()]); } else { IOException[] combined = new IOException[suppressedExceptions.size() + dexElementsSuppressedExceptions.length]; suppressedExceptions.toArray(combined); System.arraycopy(dexElementsSuppressedExceptions, 0, combined, suppressedExceptions.size(), dexElementsSuppressedExceptions.length); dexElementsSuppressedExceptions = combined; } suppressedExceptionsField.set(dexPathList, dexElementsSuppressedExceptions); } } private static Object[] makeDexElements( Object dexPathList, ArrayList<File> files, File optimizedDirectory, ArrayList<IOException> suppressedExceptions) throws IllegalAccessException, InvocationTargetException, NoSuchMethodException { Method makeDexElements = findMethod(dexPathList, "makeDexElements", ArrayList.class, File.class, ArrayList.class); return (Object[]) makeDexElements.invoke(dexPathList, files, optimizedDirectory, suppressedExceptions); } }
V19與V14差異不大,只不過DexPathList#makeDexElements方法多了一個ArrayList<IOException>參數,若是在執行DexPathList#makeDexElements方法的過程當中出現異常,後面使用反射的方式把這些異常記錄進DexPathList的dexElementsSuppressedExceptions字段裏面。
不管是V4/V14仍是V19,在建立DexFile對象的時候,都須要經過DexFile的Native方法openDexFile來打開dex文件,其具體細節暫不討論(涉及到dex的文件結構,很煩,有興趣請閱讀dalvik_system_DexFile.cpp),這個過程的主要目的是給當前的dex文件作Optimize優化處理並生成相同文件名的odex文件,App實際加載類的時候,都是經過odex文件進行的。由於每一個設備對odex格式的要求都不同,因此這個優化的操做只能放在安裝Apk的時候處理,主dex的優化咱們已經在安裝apk的時候搞定了,其他的dex就是在MultiDex#installSecondaryDexes
裏面優化的,然後者也是MultiDex過程當中,另一個耗時比較多的操做。(在MultiDex中,提取出來的dex文件被壓縮成.zip文件,又優化後的odex文件則被保存爲.dex文件。)
到這裏,MultiDex的工做流程就結束了。怎麼樣,是否是以爲和之前談到動態加載技術(插件化)的時候說的很像?沒錯,誰叫它們的核心都是dex文件呢。Java老師第一節課就說「類就是編程」,搞定類你就能搞定整個世界啊!
MultiDex有個比較蛋疼的問題,就是會產生明顯的卡頓現象,經過上面的分析,咱們知道具體的卡頓產生在解壓dex文件以及優化dex兩個步驟。不過好在,在Application#attachBaseContext(Context)中,UI線程的阻塞是不會引起ANR的,只不過這段長時間的卡頓(白屏)仍是會影響用戶體驗。
目前,優化方案能想到的有兩種。
大體思路是,在安裝一個新的apk的時候,先在Worker線程裏作好MultiDex的解壓和Optimize工做,安裝apk並啓動後,直接使用以前Optimize產生的odex文件,這樣就能夠避免第一次啓動時候的Optimize工做。
安裝dex的時候,核心是建立DexFile對象並使用其Native方法對dex文件進行opt處理,同時生產一個與dex文件(.zip)同名的已經opt過的dex文件(.dex)。若是安裝dex的時候,這個opt過的dex文件已經存在,則跳過這個過程,這會節省許多耗時。因此優化的思路就是,下載Apk完成的時候,預先解壓dex文件,並預先觸發安裝dex文件以生產opt過的dex文件。這樣覆蓋安裝Apk並啓動的時候,若是MultiDex能命中解壓好的dex和odex文件,則能避開耗時最大的兩個操做。
不過這個方案的缺點也是明顯的,第一次安裝的apk沒有做用,並且事先須要使用內置的apk更新功能把新版本的apk文件下載下來後,才能作PreMultiDex工做。
這種方案也是目前比較流行的Dex手動分包方案,啓動App的時候,先顯示一個簡單的Splash閃屏界面,而後啓動Worker線程執行MultiDex#install(Context)工做,就能夠避免UI線程阻塞。不過要確保啓動以及啓動MultiDex#install(Context)所須要的類都在主dex裏面(手動分包),並且須要處理好進程同步問題。
參考資料:
著做信息:
本文章出自 Kaede 的博客,原創文章若無特別說明,均遵循 CC BY-NC 4.0 知識共享許可協議4.0(署名-非商用-相同方式共享),能夠隨意摘抄轉載,但必須標明署名及原地址。