Flutter 動態化方案探索

YYDev發表於2019-10-30

一、背景

隨著移動平臺的發展,移動端使用者規模越來越大,相應地產品需求也是日益見長。為了解決諸多快速迭代的業務產品線及需求,提高我們的開發效率,業內的同行們嘗試探索了許多跨平臺方案,如今比較主流的方案大致有以下幾種。如:

  1. React Native;
  2. Weex;
  3. Hybrid App;
  4. Flutter;
  5. 小程式;

上述的幾種方案或多或少都存在一些瓶頸或使用場景的缺陷,這裡就不多展開討論了。下面列出主要的對照資訊,給大家一個參考:

方案名稱 React Native Weex Hybrid App Flutter 小程式
平臺實現 JS JS 無橋接 無橋接 無橋接
引擎 JSCore JS V8 原生渲染 Flutter engine -
核心語言 React Vue Java/Obeject-C Dart WXML
Apk大小(Release) 4-6M左右 10M左右 - 8-10M左右 -
bundle檔案大小 預設單一,較大 較小,多頁面可多檔案 不需要 不需要 不需要
上手難度(原生角度) 容易 一般 一般 容易 容易
框架程度 較重 較輕 較重 較輕
特點 適合開發整體App 適合單頁面 適合開發整體App 適合開發整體App 適合開發整體App
社群 豐富(FaceBook) 一般(阿里) 一般 豐富(Google) 一般(微信)
跨平臺支援 Android、iOS Android、iOS Android、iOS Android、iOS、Web、Fuchsia 等 Android、iOS

Flutter作為最近兩年發展勢頭迅猛的一種跨平臺解決方案, 進入了我們調研的視線範圍,我們主要會從以下幾個方面去衡量:

  1. 接入難度。
  2. 學習成本。
  3. 效能。
  4. 包體積。
  5. 動態化能力。

二、探索動態化方案

前面幾個方面,相信大家在接觸Flutter的時候都已經有了一些瞭解,這裡就不多作深入探討了。而作為跨平臺解決方案,動態化算是一個比較重要的功能之一,通過查資料&翻文件&技術群交流討論,發現目前在Flutter中主要有以下三種實現方案:

  1. 類似React Native 框架。
  2. 替換Flutter編譯產物。
  3. 頁面動態元件框架。

三種實現方案

接下來我們簡要介紹一下這幾個方案的具體實現原理。

1. 動態元件方案

目前,市面上大多技術團隊都是通過這種頁面動態元件的思想去實現動態化,比如閒魚、唯品會、頭條等。該方案的核心原理是在打包應用前,如在編譯期時插樁/預埋好DynamicWidget到程式碼中,然後動態下發Json 資料,通過協定好的語義匹配到JSON內的資料,動態替換Widget內容來實現動態化 (除UI外,若需要實現邏輯程式碼的動態化,則可以通過類似Lua 這種比較動態的指令碼語言寫業務邏輯)。

總結特點如下:

  1. 在市面上已經有很多與之類似的成熟框架,如天貓的Tangram,淘寶的DinamicX等。它在效能以及動態性,開發成本上取得相對較好的平衡。它能滿足常見情況的動態性需求,在一定程度上能解決實際問題。
  2. 能支援Android/iOS 兩端的動態化。
  3. UI動態化相對較容易,業務邏輯動態化較麻煩。
  4. 語義解析器開發成本相對較大,且不易維護。

1.1 關於語法樹

Tangram、DinamicX等框架它們有個共同點,都是通過Xml或者Html 做為DSL。但是Flutter 是React Style語法,Flutter自己的語法已經能很好的來表達頁面。因此,這個方案無需自定義語法。用Flutter 原始碼做為DSL即可。這樣 能大大減輕開發以及測試過程,不需要額外的工具支援。

Flutter analyze 解析原始碼得到ASTNode過程:

Flutter 動態化方案探索

從上圖可以看出,外掛或者命令對analysis server發起請求,請求中帶需要分析的檔案path,和分析的型別,analysis_server經過使用 package:analyzer 獲取 commilationUnit (ASTNode),再對ASTNode 經過computer分析,返回一個分析結果list。

根據Flutter的原理,同樣我們也可以使用 package:analyzer 把原始檔轉換為commilationUnit (ASTNode),ASTNode是一個抽象語法樹(abstract syntax tree或者縮寫為AST)是原始碼的抽象語法結構的樹狀表現形式,利用抽象語法樹能很好的解析Dart 原始碼。

方案缺陷:

需要對原始碼的格式制定規則,比如不支援 直接寫if else ,需要使用邏輯wiget元件來代替if else 語句。如果不制定規則,那AST Node 到widget node 的解析過程會很複雜。因此可以引入lua 來實現邏輯程式碼的動態化。

1.2 json +lua 方案的整體架構規劃:

Flutter 動態元件框架設計圖.jpg

開源方案:

github.com/dart-lang/s…

github.com/dengyin2000…

luakit_plugin:github.com/williamwen1…

參考資料:

dart.dev/tools/darta…

yq.aliyun.com/articles/67…

2. 類似RN的方案(JS bundle)

參考 React Native 的設計思路,總結起來就是利用 JavasSriptCore 替換DartVM,用 JavaScript(簡稱JS) 把 XML DSL 轉為 Flutter 的原子widget元件,然後再讓 Flutter 來渲染。從技術上來說是可行的,但成本也很大,這會是一個龐大的工程。

具體來說就是把 Flutter 的渲染邏輯中的三棵樹(即:WidgetTree、Element、RenderObject )中的第一棵(即:WidgetTree),放到 JS 中生成。用 JS 完整實現了 Flutter 控制元件層封裝,可以使用 JS 以類似 Dart 的開發方式,開發Flutter應用。利用JavaScript版的輕量級Flutter Runtime,生成UI描述,傳遞給Dart層的UI引擎,然後UI引擎把UI描述生產真正的 Flutter 控制元件。

手機QQ看點團隊開源方案:《基於JS的高效能Flutter動態化框架》

方案缺陷:不管JSWidget建立有多快,總是有跨語言執行,對於效能總是會有影響的。另外由於iOS系統內建支援JS,所以它在iOS上是完全動態化的,但是Android 端需要額外引入JS庫。目前MXFlutter 這套方案也僅僅實現了iOS版的動態化,並且實現起來較複雜。

3. 替換編譯產物方案

若要實現編譯產物的動態化,那麼在Android平臺上,則會被限制在JIT程式碼上;而在iOS平臺上,則會被限制在解釋執行的程式碼。谷歌Flutter團隊的之前嘗試過提供官方的解決方案,但後來放棄了並回滾了程式碼。他們是說法是對於這樣在有平臺限制下的解決方案,在iOS平臺上的效能表現能否達到預期並沒太多信心(簡單地說就是,在iOS系統上跑起來會卡得無法讓人忍受,因為iOS不像android 那樣,可以直接載入動態庫so,它需要載入的是靜態庫)因此,若採用這種編譯產物替換的方案,那麼目前只能使用在Android 端。

首先,我們得知道Flutter的編譯產物是什麼,就正如我們所熟知的Android那套編譯產物是dex檔案,通過對dex檔案的載入流程進行偷樑換柱,可以達到動態化的目的。那麼,我們先來了解一下Flutter的編譯產物,這裡需要注意的是Flutter目前的更新速度太快了,不同版本下的編譯產物也不太一致。

3.1 Flutter的編譯指令

(1)編譯apk & aar 預設引擎

// 編譯純Flutter apk,預設是release版本
flutter build apk

// 編譯純debug版apk
flutter build apk --debug

// 編譯 aar, 預設是release 版本
flutter build aar

// 編譯aar, 預設是debug版本
flutter build aar --debug
複製程式碼

關於編譯的指令,可以通過flutter build -h進行檢視,如下截圖:

Flutter 動態化方案探索

(2)編譯apk & aar 指定本地引擎

  • 關於如何編譯引擎,可以檢視這篇文章[Ubuntu 16.04 編譯Flutter Engine](/home/lichaojian/文件/Ubuntu 16.04 編譯Flutter Engine.md)

  • 如何引用本地引擎

// 指定引用本地的引擎去編譯apk,適用於純Flutter應用
flutter build apk --target-platform android-arm64 --local-engine=android_release_arm64 --local-engine-src-path=/home/lichaojian/engine/src

// 指定引用本地的引擎去編譯aar,適用於Flutter & Native 的混編專案
flutter build aar --target-platform android-arm64 --local-engine=android_release_arm64 --local-engine-src-path=/home/lichaojian/engine/src
複製程式碼

(3)檢視Flutter 編譯指令的原始碼

​ 其實無論我們是編譯apk或者是aar,都是通過flutter這個指令,所以檢視一下這個flutter指令的原始碼實際上是什麼。接下來我們可以檢視一下/your_flutter_sdk_path/bin/flutter,開啟flutter這個檔案,裡面最核心的一句話如下:

FLUTTER_TOOLS_DIR="$FLUTTER_ROOT/packages/flutter_tools"
SNAPSHOT_PATH="$FLUTTER_ROOT/bin/cache/flutter_tools.snapshot"
STAMP_PATH="$FLUTTER_ROOT/bin/cache/flutter_tools.stamp"
SCRIPT_PATH="$FLUTTER_TOOLS_DIR/bin/flutter_tools.dart"
DART_SDK_PATH="$FLUTTER_ROOT/bin/cache/dart-sdk"

DART="$DART_SDK_PATH/bin/dart"
PUB="$DART_SDK_PATH/bin/pub"

# FLUTTER_TOOL_ARGS isn't quoted below, because it is meant to be considered as
# separate space-separated args.
"$DART" --packages="$FLUTTER_TOOLS_DIR/.packages" $FLUTTER_TOOL_ARGS "$SNAPSHOT_PATH" "$@"
複製程式碼
  • $DART: 啟動一個dart虛擬機器
  • $SNAPSHOT_PATH: 指定一個可執行的snapshot檔案,路徑是/your_flutter_sdk_path/bin/cache/flutter_tools.snapshot
  • $@: 就是你傳過來的引數(例如:build apk)
// 從上面可以看出,平時我們執行的
flutter build apk

// 實際上是
/your_flutter_sdk_path/bin/cache/dart-sdk/bin/dart /your_flutter_sdk_path/bin/cache/flutter_tools.snapshot build apk
複製程式碼

執行上述指令,如下截圖,編譯apk成功,aar也是同樣的原理:

Flutter 動態化方案探索

  • 接下來檢視一下flutter_tools.snapshot的原始碼檔案,位於/your_flutter_sdk_path/flutter/package/flutter_tools/bin/flutter_tools.dart

Flutter 動態化方案探索

從上述截圖可以看出,實際是呼叫了executable.main的方法,接下來我們看一下executable.dart

Flutter 動態化方案探索

可以看出,runner這裡執行了一系列的Command類,然後我們熟悉的當然是flutter build這個命令,所以我們可以看一下flutter build的命令對應的就是BuildCommand

Flutter 動態化方案探索

從上圖可以看出,實際上,BuildCommand實際上是由很多的子Command組成來的,例如aar、apk、aot等都是屬於BuildCommand的子命令。

如果想更詳細的瞭解Flutter的打包編譯流程,推薦檢視 [研讀Flutter——打包編譯流程詳解]

3.2 Flutter不同版本下的編譯產物差異

(v1.5.4-hotfixes & v1.9.1)

  • V1.5.4-hofixed

(1)debug 模式

image-20191026064033993

(2)release模式

image-20191026064004887

  • v1.9.1

(1)debug模式

Flutter 動態化方案探索

(2)release模式

Flutter 動態化方案探索

從上面的截圖可以看出來,debug模式下,v1.5.4-hofixes以及v1.9.1的產物沒多大變化,這裡我們也不針對debug版本進行討論,可以忽略,但是我們可以發現兩者的區別如下:

v1.5.4-hotfixes release模式下產物

  • isolate_snapshot_instr
  • isolate_snapshot_data
  • vm_snapshot_data
  • assets/vm_snapshot_instr

v1.9.1 release模式下產物

  • libapp.so

著重分析v1.9.1 release模式下的產物主要分為這幾個:

  • /lib/libapp.so 主要是編譯Dart的生成的可執行檔案
  • /lib/libflutter.so 主要存放Flutter Engine 的可執行檔案
  • /assets/flutter_assets 主要存放flutter的一些資原始檔,例如字型,圖片等。

​ 可以看出,在v1.9.1版本以後,Flutter的程式碼編譯產物就變得更單一了,這是有助於我們進行動態化的研究的,我們知道,libapp.so是天生支援動態連結的。意思是我們就可以替換掉libapp.so檔案,從而達到動態化的目的。這個最開始也是立森通過直接root手機替換掉產物,發現是支援的,然後才有了我們的後續。

​ 既然是支援替換libapp.so來實現動態更新的,那麼我們怎麼通過程式碼去實現呢?

3.3 Flutter 如何動態替換編譯產物?

​ 從上面我們可以知道,Flutter的編譯產物到底有哪些東西了,所以我們通過對程式碼進行動態指定載入編譯產物的路徑即可達到動態化的效果,那麼應該怎樣對程式碼進行修改呢?有兩種方式:

(1)通過修改Flutter Engine的方式。

優點:

  • 便於熟悉Engine 程式碼。
  • 可定製擴充套件Engine。

缺點:

  • 對Engine的程式碼的入侵性較強。
  • 需要維護一個自己的Engine,對外提供。
  • 需要定期更新同步官方Engine程式碼。

(2)通過Hook 的方式。

優點:對Engine程式碼入侵性較小。

缺點:需要維護SDK,Engine版本更新時,需跟進hook點是否需要替換。

3.3.1 so檔案的替換流程

  • (1)Android 是如何載入so檔案的。

    ​ 通過上面介紹的編譯產物,可以看出,release版本下,Android下,目前flutter會編譯成一個libapp.so檔案,那麼Android本身載入so檔案的方式有哪幾種呢?主要分為以下兩種:

// 預設載入路徑載入,對應~/app/libs
System.loadLibrary("libname")
    
// 通過絕對路徑進行載入
System.load("/your_so_path/libupdate.so")
複製程式碼

這兩種方式的主要區別,就是loadLibrary通過載入app下的libs目錄的so檔案,load的話是通過載入其絕對路徑載入。

關於Android當中,載入.so檔案的原理,可以看一下gityuan的 loadLibrary動態庫載入過程分析,也可以看一下

深入理解System.loadLibrary 這篇文章。

簡單的說,都是通過呼叫dlfcn.h 這個標頭檔案下的函式,如下:

void *dlopen(const char *filename, int flag);  //開啟動態連結庫
char *dlerror(void);   //獲取錯誤資訊
void *dlsym(void *handle, const char *symbol);  //獲取方法指標
int dlclose(void *handle); //關閉動態連結庫  
複製程式碼

​ 瞭解完Android是如何載入so檔案的,接下來看一下Flutter是如何載入so檔案的。

  • (2)Flutter 是如何載入so檔案的。
  1. 初始化Flutter,通過檢視原始碼,我們知道必須呼叫的方法有兩個。

    FlutterMain.startInitialization(@NonNull Context applicationContext)
    FlutterMain.ensureInitializationComplete(@NonNull Context applicationContext, @Nullable String[] args)
    複製程式碼

此處省略很多.......直接進入重點....

native_library_posix.cc

NativeLibrary::NativeLibrary(const char* path) {
  ::dlerror();

  FML_LOG(ERROR)<< "lichaojian-path = " << path;
  
  handle_ = ::dlopen(path, RTLD_NOW);
  if (handle_ == nullptr) {
    FML_DLOG(ERROR) << "Could not open library '" << path << "' due to error '"
                    << ::dlerror() << "'.";
  }
}

fml::RefPtr<NativeLibrary> NativeLibrary::Create(const char* path) {
  auto library = fml::AdoptRef(new NativeLibrary(path));
  FML_LOG(ERROR)<< "lichaojian-Create = " << path;
  return library->GetHandle() != nullptr ? library : nullptr;
}
複製程式碼

從上述指令可以看出,實際上也是呼叫dlopen來載入so庫的。所以知道這個原理之後,我們就知道怎麼處理了,我在這兩個函式加的日誌列印如下:

Flutter 動態化方案探索

知道了原理之後,實現Flutter動態載入so檔案的方式主要分為兩部分:

​ (1) native層

通過更改native層程式碼,讓native層判斷某個預定好的路徑是否存在更新的檔案,存在的話,則進行載入更新的檔案,不存在的話,則載入原來的libapp檔案。
複製程式碼

(2) java 層

​ 剛才在FlutterMain#ensureInitializationComplete方法當中,我們可以看到libapp相關的引數當中,有兩行程式碼至關重要,我們來回顧一下:

private static final String DEFAULT_AOT_SHARED_LIBRARY_NAME = "libapp.so";
private static String sAotSharedLibraryName = DEFAULT_AOT_SHARED_LIBRARY_NAME;

shellArgs.add("--" + AOT_SHARED_LIBRARY_NAME + "=" + sAotSharedLibraryName);

                // Most devices can load the AOT shared library based on the library name
                // with no directory path.  Provide a fully qualified path to the library
                // as a workaround for devices where that fails.
                shellArgs.add("--" + AOT_SHARED_LIBRARY_NAME + "=" + applicationInfo.nativeLibraryDir + File.separator + sAotSharedLibraryName);
複製程式碼

通過上述程式碼可以發現,這裡把aot_share_library_name以及其路徑都載入到了shellArgs引數當中,所以我們可以通過在java層更改這個路徑以及名稱,從而達到動態載入so的目的。為什麼連名稱都要更改,如果名稱不更改的化,找到libapp.so這個名稱的時候,會直接對映到lib目錄下的libapp.so這個檔案,所以導致動態載入so失效。

通過替換一個路徑已經更改名字的so檔案,達到動態載入。

3.3.2 資源的替換

關於flutter資源

相比起Android系統的資源管理,flutter對資源的管理實在是簡單得太多了,flutter的資源沒有經過編譯的任何處理,完全是以原始檔的形式暴露出來,獲取資源就是以檔案的讀取方式來進行,返回給到flutter的是資源在記憶體中的buffer內容,資源是以目錄名+檔名來標識,如下:

Flutter 動態化方案探索

關於flutter AssetManager

flutter engine內部也有一個AssetManager,原始碼路徑是flutter/assets/asset_manager.h AssetManager的程式碼不多,只是內部維護了AssetResolver的一個佇列,核心的方法有兩個

//往佇列裡面新增一個AssetResolver
void AssetManager::PushBack(std::unique_ptr<AssetResolver> resolver) {
  if (resolver == nullptr || !resolver->IsValid()) {
    return;
  }

  resolvers_.push_back(std::move(resolver));
}

//檢索資源
std::unique_ptr<fml::Mapping> AssetManager::GetAsMapping(
    const std::string& asset_name) const {
  if (asset_name.size() == 0) {
    return nullptr;
  }
  TRACE_EVENT1("flutter", "AssetManager::GetAsMapping", "name",
               asset_name.c_str());
  for (const auto& resolver : resolvers_) {
    auto mapping = resolver->GetAsMapping(asset_name);
    if (mapping != nullptr) {
      return mapping;
    }
  }
  FML_DLOG(WARNING) << "Could not find asset: " << asset_name;
  return nullptr;
}
複製程式碼

從程式碼裡面可以看得出來,其實真正的資源是由AssetResolver提供的。

關於flutter AssetResolver

AssetResolver是個介面類,flutter資源提供者必須要實現這個介面原始碼是在flutter/assets/asset_resolver.h 下面,定義大致如下

namespace flutter {

class AssetResolver {
 public:
  // 無關重要的被我省略了。。。
  virtual std::unique_ptr<fml::Mapping> GetAsMapping(
      const std::string& asset_name) const = 0;

 private:
  FML_DISALLOW_COPY_AND_ASSIGN(AssetResolver);
};

}  // namespace flutter
複製程式碼

其中最為核心的就是GetAsMapping方法,此方法返回了一個檔案的MappingMapping也是個介面類,其定義也極為簡單,原始碼是在 flutter/fml/mapping.h下面,這裡直接給出

class Mapping {
 public:
  // 無關重要的被我省略了。。。
  virtual size_t GetSize() const = 0;
  virtual const uint8_t* GetMapping() const = 0;
};
複製程式碼

其中GetSize 返回了檔案的大小,GetMapping返回的是資源在記憶體中的地址,整個資源的管理結構大致如下圖所示:

Flutter 動態化方案探索

關於flutter APKAssetProvider

APKAssetProvider實現了AssetResolver介面,為flutter提供了Android平臺下的資源獲取能力,其本質就是把Java層的AssetManager通過AAssetManager_fromJava介面轉換到C++層,然後再通過AAssetManager_open AAsset_getBuffer AAsset_close等NDK介面來讀取Asset資源, 原始碼路徑在flutter/shell/platform/android/apk_asset_provider.h 下面,程式碼也不多,這裡直接給出呼叫流程

Flutter 動態化方案探索

關於flutter資源動態部署的幾種方案

  • Android平臺

通過前面的程式碼分析,我們可以清楚的看見,在Android平臺下面,flutter的資源其實也是由AssetManager提供的,所以我們可以借鑑熱修復的原理(其實比熱修復還簡單得多,因為這裡我們不需要做全量合成,只要做一次半全量合成就可以了,也不需要去replace系統的AssetManager只管調addAssetPath就可以了)。 當然,用這種方案的話必須要解決Android 9對私有API的限制問題。

  • 跨平臺通用方式

上面的方案弊端是很明顯的,第一隻能滿足Android平臺,第二需要解決系統對私有API的約束問題等,其實在做第一種方案前,作者我就已經先實現了基於c++層的跨平臺通用方式,其過程及原理也是非常的簡單,通過前面的分析,我們只要實現一個自己的AssetResolverMapping,然後把AssetResolver塞到flutter的AssetManager佇列裡面就可以了。

  • 利用flutter提供的原生支援方案

這種方式是昨晚在寫此文章時才發現的,所以暫時還沒有經過驗證,不過從理論上來講也是可行的,並且就目前來看應該是最簡單,最有效的一種方案。

RunConfiguration裡面我們能找到如下程式碼(原始碼路徑flutter/shell/common/run_configuration.h

RunConfiguration RunConfiguration::InferFromSettings(
    const Settings& settings,
    fml::RefPtr<fml::TaskRunner> io_worker) {
  // 下面無關重要的程式碼已經被我刪除了。。。
  if (fml::UniqueFD::traits_type::IsValid(settings.assets_dir)) {
    asset_manager->PushBack(std::make_unique<DirectoryAssetBundle>(
        fml::Duplicate(settings.assets_dir)));
  }
  asset_manager->PushBack(
      std::make_unique<DirectoryAssetBundle>(fml::OpenDirectory(
          settings.assets_path.c_str(), false, fml::FilePermission::kRead)));
}
複製程式碼

實際上這裡的InferFromSettings是給fuchsia用的(flutter跨平臺,在engine工程裡面隨處都能看見類似於fuchsia android ios windows linux darwin等等目錄結構),我們不能直接調這個函式,但是DirectoryAssetBundle卻是可以公共的(事實上ios平臺也是沒有像Android平臺那樣包裝一個APKAssetProvider出來,ios也是直接使用DirectoryAssetBundle的) DirectoryAssetBundle本質上也是AssetResolver的一個實現,原始碼路徑是在flutter/assets/directory_asset_bundle.h下面,這裡就不再分析了,有興趣的可以直接去看下。

3.3.3 實現流程

方案大致流程

Flutter 動態化方案探索

這裡實現的原理大致與Tinker 等Android 熱更新方案類似,通過對比新舊版本的檔案差異,生成一份補丁包。然後將補丁包放到伺服器,下發給舊版本APK的使用者。之後下載好再在本地解壓,將補丁包合併以實現全量替換。

差分包的生成與合併

在這塊走了一些彎路,一開始在網上找的時候,都是推薦了bsdiff和bspatch,但是官網只有c的程式碼,這時候,我比較懵逼,就直接通過NDK的方式,直接移植程式碼到Android平臺上,在移植編譯動態庫的時候,就踩了一些坑把,但是主要還是一些不熟悉CMake以及c++引起的新手坑。

關於bsdiff的一些參考連結:

bsdiff.pdf

bsdiff演算法

Google 的差量更新,實際上也是用了bsdiff

Tinker 基於 bsdiff v4.2封裝的java程式碼

關於差分包的生成與合併,都是用現成的框架,所以難度並不會很大。

總結

本文主要探索並講解了Flutter 中目前主流的三種動態化實現方案,動態元件方案以及類似RN這種Js 方案,本質上都是通過AST 解析語義樹來實現的。而編譯產物的動態化,通過分析原始碼發現目前能夠在Android 平臺實現,iOS平臺則還沒有太好的解決方案。Android的產物編譯動態化方案,目前來說實現起來相對容易些,難度不算太大。在探索過程中或多或少踩過一些坑,文章若有不足之處還望大家多多指正~

感謝閱讀~

作者

Flutter 動態化方案探索
xiaosongzeem

相關文章