Tinker快速集成

本文使用的是tinker的1.9.6版本,使用gradle方式接入。具體的接入方式可參考官方接入指南
需要特別注意:
1. Tinker不支持修改AndroidManifest.xml,Tinker不支持新增四大組件(1.9.0開始支持新增非export的Activity);
2. 由於Google Play的開發者條款限制,不建議在GP渠道動態更新代碼(實際上大概率會過不了審);
3. 在Android N上,補丁對應用啓動時間有輕微的影響;
4. 不支持部分三星android-21機型,加載補丁時會主動拋出”TinkerRuntimeException:checkDexInstall failed”;
5. 對於資源替換,不支持修改remoteView,例如transition動畫,notification icon以及桌面圖標;
6. 目前tinker熱更新so包的話,只支持armeabi目錄下的庫文件。

引入tinker

在項目根build.gradle中:

buildscript {
    repositories {
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:2.3.1'
        // 引入tinker插件
        classpath "com.tencent.tinker:tinker-patch-gradle-plugin:1.9.6"
    }
}

在項目app的build.gradle裏配置基本引入:

// 用git號做TINKER_ID
def getTinkerIdValue() {
    return hasProperty("TINKER_ID") ? TINKER_ID : gitSha()
}

def gitSha() {
    try {
        String gitRev = 'git rev-parse --short HEAD'.execute(null, project.rootDir).text.trim()
        if (gitRev == null) {
            throw new GradleException("can't get git rev, you should add git to system path or just input test value, such as 'testTinkerId'")
        }
        return gitRev
    } catch (Exception e) {
        throw new GradleException("can't get git rev, you should add git to system path or just input test value, such as 'testTinkerId'")
    }
}

android {
    ...
    defaultConfig {
        ...
        // 配置TINKER_ID
        buildConfigField "String", "TINKER_ID", "\"${getTinkerIdValue()}\""

        // 必須開啓multiDex
        multiDexEnabled true
    }
    // tinker建議開啓此項,否則若Application所直接使用的類沒有被打在main dex中,就會導致patch失敗
    dexOptions {
        // 忽略方法數限制的檢查
        jumboMode = true
    }

    sourceSets {
        main {
            ...
            // 若要熱修復so包,需要指明so包路徑
            jniLibs.srcDirs = ['libs']
        }
    }
}

dependencies {
    compile fileTree(include: ['*.*'], dir: 'libs')
    ...

    // 若使用>=3的gradle,tinker_version=1.9.6
    implementation "com.android.support:multidex:1.0.1"
    implementation("com.tencent.tinker:tinker-android-lib:${rootProject.ext.tinker_version}") {
        changing = true
    }

    // 用於編譯時生成application類
    annotationProcessor("com.tencent.tinker:tinker-android-anno:${rootProject.ext.tinker_version}") {
        changing = true
    }
    compileOnly("com.tencent.tinker:tinker-android-anno:${rootProject.ext.tinker_version}") {
        changing = true
    }

    // 若使用<3的gradle
    //compile "com.android.support:multidex:1.0.1"
    //compile("com.tencent.tinker:tinker-android-lib:${rootProject.ext.tinker_version}") {
    //    changing = true
    //}
    //provided("com.tencent.tinker:tinker-android-anno:${rootProject.ext.tinker_version}") {
    //    changing = true
    //}
}

// 其他簽名啊,混淆啊啥的都正常配置就行

代碼引入方面,此處基本可以照搬示例代碼:
這裏寫圖片描述
在Application接入時,Tinker藉助註解來生成Application

/**
* 用於生成實際Application的註解類,這樣做是因爲,所有與Application直接使用的類都需要被打在主dex中。
* 同時,儘量避免在Application中做多餘的工作。
*/
@SuppressWarnings("unused")
@DefaultLifeCycle(
    // 定義生成的Application類,與AndroidManifest中的application節點name屬性一致,爲避免錯誤,必須是全名
    application = "cn.com.bluemoon.delivery.AppContext",
    // TINKER_ENABLE_ALL:支持dex、lib(so包)、資源的更新
    flags = ShareConstants.TINKER_ENABLE_ALL,
    // 在加載時是否校驗dex、lib與res的md5,默認false
    loadVerifyFlag = false
    // loaderClass: 定義tinker的類加載器,默認爲TinkerLoader)
public class HFApplicationLike extends DefaultApplicationLike {
    ...
    public HFApplicationLike(Application application, int tinkerFlags, boolean tinkerLoadVerifyFlag,
                             long applicationStartElapsedTime, long applicationStartMillisTime, Intent tinkerResultIntent) {
        super(application, tinkerFlags, tinkerLoadVerifyFlag, applicationStartElapsedTime, applicationStartMillisTime, tinkerResultIntent);
    }

    /**
     * 在安裝tinker前安裝multiDex,以避免將tinker放進主dex
     */
    @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
    @Override
    public void onBaseContextAttached(Context base) {
        super.onBaseContextAttached(base);
        // 必須先執行MultiDex的install
        MultiDex.install(base);

        // 賦值全局使用的application單例以及其context,其實就是生成的application的實例
        AppContextHolder.application = getApplication();
        AppContextHolder.context = getApplication();

        TinkerManager.setTinkerApplicationLike(this);

        // 沒設置uncaughtExceptionHandler,就用默認的SampleUncaughtExceptionHandler
        TinkerManager.initFastCrashProtect();
        // 必須在tinker安裝前設置
        TinkerManager.setUpgradeRetryEnable(true);

        // 設置log的實現,可以使用默認實現
        TinkerInstaller.setLogIml(new MyLogImp());

        // 在加載multiDex後執行,否則就需要將com.tencent.tinker.**手動放到主dex,麻煩
        // 配置tinker其他項
        TinkerManager.installTinker(this);
        Tinker tinker = Tinker.with(getApplication());
    }

    /**
    * 可重寫onCreate() 、onLowMemory()、onTrimMemory(int level)、onTerminate()、onConfigurationChanged,對應的調用時機與實際Application中的一致
    */
    @Override
    public void onCreate() {
        super.onCreate();
    }

其中TinkerManager是協助Tinker初始化的輔助類,具體可見sample代碼,此處只關注:


public class TinkerManager {
    ...

    public static void installTinker(ApplicationLike appLike) {
        if (isInstalled) {
            TinkerLog.w(TAG, "install tinker, but has installed, ignore");
            return;
        }
        // Tinker在加載補丁時的一些回調,默認實現爲DefaultLoadReporter
        LoadReporter loadReporter = new SampleLoadReporter(appLike.getApplication());
        // Tinker在修復或者升級補丁時的一些回調,默認實現爲DefaultPatchReporter
        PatchReporter patchReporter = new SamplePatchReporter(appLike.getApplication());
        // 用來過濾Tinker收到的補丁包的修復、升級請求,也就是決定是不是真的要喚起:patch進程去嘗試補丁合成,默認實現爲DefaultPatchListener
        PatchListener patchListener = new SamplePatchListener(appLike.getApplication());
        // 用來升級當前補丁包的處理類,一般來說不需要複寫
        AbstractPatch upgradePatchProcessor = new UpgradePatch();

        TinkerInstaller.install(appLike,
            loadReporter, patchReporter, patchListener,
            SampleResultService.class, upgradePatchProcessor);

        isInstalled = true;
    }
}

詳細的配置說明可見Tinker自定義擴展

最後,需要在AndroidManifest中做做些微修改:

<!-- 權限添加 -->
   <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
   <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

<!-- name的值與ApplicationLike的application值一致  -->
<application
    android:name=".AppContext"
    ...>
        <!-- :patch補丁合成進程將合成結果返回給主進程的類,默認實現爲DefaultTinkerResultService  -->
       <service
           android:name=".tinker.service.SampleResultService"
           android:exported="false"/>
</application>

配置patch

patch的配置在app的build.gradle中定義,詳細的配置可見官方指南

// 生成備份的apk、混淆mapping文件、資源R文件的目錄,其實就是將每次build出來的相應文件以別名備份
def bakPath = file("${buildDir}/bakApk/")
// bakPath裏的備份包名稱,用於與新包作比較得出補丁包
def curApkName = "月亮天使_20180514_09_測試版_4.9.4(1726)_0514-09-20-59"

/**
 * 手動的話,可以先使用assembleRelease編譯出基礎包,
 * 再使用"tinkerPatchRelease -POLD_APK=...  -PAPPLY_MAPPING=...  -PAPPLY_RESOURCE=..."命令生成補丁包
 */
ext {
    // 是否執行tinker。開發期間,可以將這個關了來使用instant run。btw,目前instant run與tinker是互斥的。
    tinkerEnabled = true

    // 舊包(用於打補丁的基礎舊包)apk名
    tinkerOldApkPath = "${bakPath}/${curApkName}.apk"
    // 舊包mapping文件
    tinkerApplyMappingPath = "${bakPath}/${curApkName}-mapping.txt"
    // 舊包R文件
    tinkerApplyResourcePath = "${bakPath}/${curApkName}-R.txt"

    //only use for build all flavor, if not, just ignore this field,暫未用過
    tinkerBuildFlavorDirectory = "${bakPath}/app-1018-17-32-47"
}

////////// 以下都是一些輔助方法(start) /////////////
def getOldApkPath() {
    return hasProperty("OLD_APK") ? OLD_APK : ext.tinkerOldApkPath
}

def getApplyMappingPath() {
    return hasProperty("APPLY_MAPPING") ? APPLY_MAPPING : ext.tinkerApplyMappingPath
}

def getApplyResourceMappingPath() {
    return hasProperty("APPLY_RESOURCE") ? APPLY_RESOURCE : ext.tinkerApplyResourcePath
}

// 用git號做版本號
def gitSha() {
    try {
        String gitRev = 'git rev-parse --short HEAD'.execute(null, project.rootDir).text.trim()
        if (gitRev == null) {
            throw new GradleException("can't get git rev, you should add git to system path or just input test value, such as 'testTinkerId'")
        }
        return gitRev
    } catch (Exception e) {
        throw new GradleException("can't get git rev, you should add git to system path or just input test value, such as 'testTinkerId'")
    }
}

def getTinkerIdValue() {
    return hasProperty("TINKER_ID") ? TINKER_ID : gitSha()
}

def buildWithTinker() {
    return hasProperty("TINKER_ENABLE") ? Boolean.parseBoolean(TINKER_ENABLE) : ext.tinkerEnabled
}

def getTinkerBuildFlavorDirectory() {
    return ext.tinkerBuildFlavorDirectory
}
////////// 以上都是一些輔助方法(end) /////////////

if (buildWithTinker()) {
    // 引入tinker的patch插件
    apply plugin: 'com.tencent.tinker.patch'

    // tinkerPatch參數配置 
    tinkerPatch {
        /**
         * 必填,默認爲null
         * 舊包路徑,用作與新包比對後得出差分包
         * 一般是build/bakApk下
         */
        oldApk = getOldApkPath()
        /**
         * 選填,默認爲false
         * 以下情況會出現warnings,若設置ignoreWarning爲true,只會assert加載補丁進程:
         * 1、minSdkVersion < 14, 但設置了dexMode爲raw,此騷操作在加載補丁時必崩;
         * 2、在AndroidManifest.xml中添加新的四大組件(1.9.0版本以上非export的Activity除外),同上,必崩;
         * 3、在以下dex.loader中的用於加載補丁的類沒有在主dex中,這樣的話,tinker不會起作用;
         * 4、dex.loader中的用於加載補丁的類修改,加載器類是用來加載補丁的,在新包修改的話也不會起作用。此操作不會引發崩潰,但也不會起效,可以忽略;
         * 5、resources.arsc變更了,但沒有設置applyResourceMapping(applyResourceMapping=null)用於編譯
         */
        ignoreWarning = false

        /**
         * 選填,默認爲true
         * 是否需要對補丁簽名
         * false時需要手動簽名,否則在加載補丁時無法通過檢查
         * 這裏使用的是android.buildTypes.xx.signingConfig的配置
         */
        useSign = true

        /**
         * 選填,默認爲true
         * 此處同ext.tinkerEnabled 
         */
        tinkerEnable = buildWithTinker()

        /**
         * applyMapping會影響正常的編譯
         */
        buildConfig {
            /**
             * 選填,默認爲null
             * 若使用tinkerPatch命令去打補丁包, 並且開啓了minifyEnabled混淆,建議用舊包的mapping文件。
             * 在編譯新的apk時候,通過保持舊apk的proguard混淆方式,從而減少補丁包的大小。
             * 警告:
             * 此操作會影響正常的編譯過程
             */
            applyMapping = getApplyMappingPath()
            /**
             * 選填,默認爲null
             * 使用舊apk的R.txt文件保持ResId的分配,這樣不僅可以減少補丁包的大小,同時也避免由於ResId改變導致remote view異常。
             */
            applyResourceMapping = getApplyResourceMappingPath()

            /**
             * 必填,默認爲null
             * 由於不希望在運行時檢測基礎apk(舊包)的md5(慢),
             * 這裏在打補丁包的時候,使用了tinkerId來標識基礎apk的版本。
             * 此處使用的是gitSha(),同時,thinkerId會自動的被寫到AndroidManifest中
             */
            tinkerId = getTinkerIdValue()

            /**
             * 若爲true,多dex時會按照基準包的類分佈來編譯,可以減少dex差分包大小。低版本的tinker此選項開啓後有bug。
             */
            keepDexApply = false

            /**
             * 選填,默認爲false
             * 是否使用加固模式,僅僅將變更的類合成補丁。注意,這種模式僅僅可以用於加固應用中。
             */
            isProtectedApp = false

            /**
             * 選填,默認爲false
             * 是否支持新增非export的Activity,只有在再次啓動加載補丁後,此Activity才起作用
             */
            supportHotplugComponent = true
        }

        // dex相關的配置項
        dex {
            /**
             * 選填,默認爲jar
             * 取值爲raw或jar, 
             * raw模式會保持輸入dex的格式,
             * jar模式,會把輸入dex重新壓縮封裝到jar,如果minSdkVersion<14,必須選擇jar模式,而且它更省存儲空間,但是驗證md5時比raw模式耗時。默認並不會去校驗md5,一般情況下選擇jar模式即可。
            dexMode = "jar"

            /**
             * 必填,默認爲[]
             * 需要處理dex路徑,支持*、?通配符,必須使用'/'分割。
             * 路徑是相對安裝包的,例如assets/...
             */
            pattern = ["classes*.dex",
                       "assets/secondary-dex-?.jar"]

            /**
             * 必填,默認爲[]
             * 此項非常重要,它定義了哪些類在加載補丁包的時候會用到。這些類是通過tinker無法修改的類,也是一定要放在main dex的類。
             * 必須把以下的類放進這裏:
             * 1、自定義的Application類(爲避免版本問題,此處最好把ApplicationLike生成的Application也加進來);
             * 2、Tinker庫中用於加載補丁包的部分類,即com.tencent.tinker.loader.*; 
             * 3、若自定義了TinkerLoader,需要將它以及它引用的所有類也加入loader中;
             * 4、其他一些不希望被更改的類,例如Sample中的BaseBuildInfo類。這裏需要注意的是,這些類的直接引用類也需要加入到loader中,或者需要將這個類變成非preverify。
             * 5、使用1.7.6版本之後的gradle版本,參數1、2會自動填寫。若使用newApk或者命令行版本編譯,1、2依然需要手動填寫
             */
            loader = [
                    "cn.com.bluemoon.delivery.AppContext",
                    //use sample, let BaseBuildInfo unchangeable with tinker
                    "cn.com.bluemoon.delivery.tinker.app.BaseBuildInfo"
            ]
        }

        // lib相關的配置項
        lib {
            /**
             * 選填,默認爲[]
             * 需要處理的lib路徑,支持*、?通配符,必須使用'/'分割。與dex.pattern一致, 路徑是相對安裝包的。
             * 對於在assets中的lib,tinker只在補丁包中回覆它,可以在TinkerLoadResult中拿到
             */
            pattern = ["lib/*/*.so"]
        }

        // res相關的配置項
        res {
            /**
             * 選填,默認爲[]
             * 需要處理的res路徑,支持*、?通配符,必須使用'/'分割。與dex.pattern一致, 路徑是相對安裝包的。
             * 所有的資源文件都必須包含進來,否則否則不會再新包中被重新打包
             */
            pattern = ["res/*", "assets/*", "resources.arsc", "AndroidManifest.xml"]

            /**
             * 選填,默認爲[]
             * 支持*、?通配符,必須使用'/'分割。若滿足ignoreChange的pattern,在編譯時會忽略該文件的新增、刪除與修改。 
             * 只能用於不與resources.arsc相關聯的文件
             */
            ignoreChange = ["assets/sample_meta.txt"]

            /**
             * 默認100kb
             * 對於修改的資源,如果大於largeModSize,將使用bsdiff算法。這可以降低補丁包的大小,但是會增加合成時的複雜度。
             */
            largeModSize = 100
        }

        // 用於生成補丁包中的'package_meta.txt'文件
        packageConfig {
            /**
             * 選填
             * 
             * we will get the TINKER_ID from the old apk manifest for you automatic,
             * other config files (such as patchMessage below)is not necessary
             * 默認自動從基準安裝包與新安裝包的Manifest中讀取tinkerId,並自動寫入configField。在這裏,可以定義其他的信息,在運行時可以通過自定義的ownPackageCheck方法裏的securityCheck.getPackageProperties()或TinkerLoadResult.getPackageConfigByName得到相應的數值。但是建議直接通過修改代碼來實現,例如BuildConfig。
             * 以下都是例子
             */
            configField("patchMessage", "tinker is sample to use")
            configField("platform", "all")
            configField("patchVersion", "1.0")
        }

        /**
         * 7zip路徑配置項,執行前提是useSign爲true
         * 若不使用zipArtifact或者path, 會自動試用7za。
         */
        sevenZip {
            /**
             * 選填,默認'7za'
             * 例如"com.tencent.mm:SevenZip:1.1.10",將自動根據機器屬性獲得對應的7za運行文件,推薦使用。
             */
            zipArtifact = "com.tencent.mm:SevenZip:1.1.10"
            /**
             * 選填,默認'7za'
             * 系統中的7za路徑,例如"/usr/local/bin/7za"。path設置會覆蓋zipArtifact,若都不設置,將直接使用7za去嘗試。
             */
//        path = "/usr/local/bin/7za"
        }
    }
}

// 多flavors
List<String> flavors = new ArrayList<>()
project.android.productFlavors.each { flavor ->
    flavors.add(flavor.name)
}
boolean hasFlavors = flavors.size() > 0
def date = new Date().format("MMdd-HH-mm-ss")

/** 
 * 備份任務
 */
android.applicationVariants.all { variant ->
    def taskName = variant.name
    tasks.all {
        if ("assemble${taskName.capitalize()}".equalsIgnoreCase(it.name)) {
            it.doLast {
                copy {
                    def fileNamePrefix = "${project.name}-${variant.baseName}"
                    // 備份文件名
                    def newFileNamePrefix = hasFlavors ? "${fileNamePrefix}" : "${fileNamePrefix}-${date}"

                    // 將生成的apk包備份到bak文件夾下,以newFileNamePrefix.apk的名字
                    def destPath = hasFlavors ? file("${bakPath}/${project.name}-${date}/${variant.flavorName}") : bakPath
                    from variant.outputs.first().outputFile
                    into destPath
                    rename { String fileName ->
                        fileName.replace("${fileNamePrefix}.apk", "${newFileNamePrefix}.apk")
                    }

                    // 同理,備份mapping.txt 
                    from "${buildDir}/outputs/mapping/${variant.dirName}/mapping.txt"
                    into destPath
                    rename { String fileName ->
                        fileName.replace("mapping.txt", "${newFileNamePrefix}-mapping.txt")
                    }

                    // 同理,備份R.txt 
                    from "${buildDir}/intermediates/symbols/${variant.dirName}/R.txt"
                    into destPath
                    rename { String fileName ->
                        fileName.replace("R.txt", "${newFileNamePrefix}-R.txt")
                    }
                }
            }
        }
    }
}

// 以下爲多flavors打包用到的,本文未涉及
project.afterEvaluate {
    if (hasFlavors) {
        task(tinkerPatchAllFlavorRelease) {
            group = 'tinker'
            def originOldPath = getTinkerBuildFlavorDirectory()
            for (String flavor : flavors) {
                def tinkerTask = tasks.getByName("tinkerPatch${flavor.capitalize()}Release")
                dependsOn tinkerTask
                def preAssembleTask = tasks.getByName("process${flavor.capitalize()}ReleaseManifest")
                preAssembleTask.doFirst {
                    String flavorName = preAssembleTask.name.substring(7, 8).toLowerCase() + preAssembleTask.name.substring(8, preAssembleTask.name.length() - 15)
                    project.tinkerPatch.oldApk = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release.apk"
                    project.tinkerPatch.buildConfig.applyMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release-mapping.txt"
                    project.tinkerPatch.buildConfig.applyResourceMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release-R.txt"

                }

            }
        }

        task(tinkerPatchAllFlavorDebug) {
            group = 'tinker'
            def originOldPath = getTinkerBuildFlavorDirectory()
            for (String flavor : flavors) {
                def tinkerTask = tasks.getByName("tinkerPatch${flavor.capitalize()}Debug")
                dependsOn tinkerTask
                def preAssembleTask = tasks.getByName("process${flavor.capitalize()}DebugManifest")
                preAssembleTask.doFirst {
                    String flavorName = preAssembleTask.name.substring(7, 8).toLowerCase() + preAssembleTask.name.substring(8, preAssembleTask.name.length() - 13)
                    project.tinkerPatch.oldApk = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug.apk"
                    project.tinkerPatch.buildConfig.applyMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug-mapping.txt"
                    project.tinkerPatch.buildConfig.applyResourceMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug-R.txt"
                }

            }
        }
    }
}

加上使用補丁包代碼

使用的時候,通常用的是生成的7zip包,在得到補丁包後,調用:

TinkerInstaller.onReceiveUpgradePatch(getApplicationContext(), 
// 補丁包絕對路徑
Environment.getExternalStorageDirectory().getAbsolutePath() + "/Download/patch_signed_7zip.apk");

若要手動清除補丁,可調用:

// 清除補丁
Tinker.with(getApplicationContext()).cleanPatch();

打出補丁包

  1. 接入、配置好tinker後,使用 assemblexxx(如assembleDeug)編譯出基礎包(舊包);
  2. 修改代碼,並將app的build.gradle中的curApkName改爲bak文件夾裏的備份基礎包名(不含後綴);
  3. 使用tinkerPatchxxx(如tinkerPathDebug)命令,即可得出補丁文件:
    這裏寫圖片描述
    生成文件中主要關注的是:
文件名 描述
patch_unsigned.apk 沒有簽名的補丁包
patch_signed.apk 簽名後的補丁包
patch_signed_7zip.apk 簽名後並使用7zip壓縮的補丁包,也是我們通常使用的補丁包。但正式發佈的時候,最好不要以.apk結尾,防止被運營商挾持
log.txt 在編譯補丁包過程的控制檯日誌
dex_log.txt 在編譯補丁包過程關於dex的日誌
so_log.txt 在編譯補丁包過程關於lib的日誌
tinker_result 最終在補丁包的內容,包括diff的dex、lib以及assets下面的meta文件
resources_out.zip 最終在手機上合成的全量資源apk,你可以在這裏查看是否有文件遺漏
tempPatchedDexes 在Dalvik與Art平臺,最終在手機上合成的完整Dex,我們可以在這裏查看dex合成的產物

檢驗成果

將補丁文件保存到設備,如上上節中的Environment.getExternalStorageDirectory().getAbsolutePath() + “/Download/patch_signed_7zip.apk”,調用TinkerInstaller.onReceiveUpgradePatch即可加載補丁,加載完成的回調中一般需要將補丁包刪除。等待patch進程完成後,重新啓動應用(必須完全kill掉進程重新啓動),即可完成補丁加載。

參考與深度閱讀

你必須知道的APT、annotationProcessor、android-apt、Provided、自定義註解
類加載機制系列2——深入理解Android中的類加載器
Android熱修復技術選型——三大流派解析
Tinker 接入指南
Android 熱修復 Tinker接入及源碼淺析

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章