Flutter系列二:探究Flutter App在Android宿主App中的整合

chonglingliu發表於2021-03-08

前面一篇文章我們探討了一下Flutter App如何被整合到iOS App中的,本文我們接著來討論下Flutter App如何被整合到Android App中的。

Gradle

我們簡單看一下Android專案的程式碼結構:

Android專案的程式碼結構

作為Android專案的自動化構建工具,我們先來看看GradleFlutter APP的構建過程中大概做了哪些工作。

settings.gradle

settings.gradle中主要是用來配置Android Project中所有需要依賴的module,即進行工程樹的配置。

settings.gradle
// 1
include ':app'

// 2
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
def properties = new Properties()

assert localPropertiesFile.exists()
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }

def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
// 3
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
複製程式碼
  1. 首先引入app module
  2. 讀取local.properties這個檔案中的flutter.sdk屬性的值,賦值給flutterSdkPath這個變數
  • local.properties中除了Android SDK路徑,還定義了Flutter相關的一些值。如Flutter SDK路徑Flutter構建模式Flutter版本編號等
local.properties
sdk.dir=/Users/*/Library/Android/sdk
flutter.sdk=/Users/*/Documents/flutter
flutter.buildMode=debug
flutter.versionName=1.0.0
flutter.versionCode=1
複製程式碼
  1. 引入"$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"中的指令碼

我們接下來來看看app_plugin_loader.gradle指令碼的程式碼:

app_plugin_loader.gradle
import groovy.json.JsonSlurper

def flutterProjectRoot = rootProject.projectDir.parentFile

// 1 找到配置檔案
def pluginsFile = new File(flutterProjectRoot, '.flutter-plugins-dependencies')
if (!pluginsFile.exists()) {
  return
}

// 2 讀取配置檔案
def object = new JsonSlurper().parseText(pluginsFile.text)
assert object instanceof Map
assert object.plugins instanceof Map
assert object.plugins.android instanceof List
object.plugins.android.each { androidPlugin ->
  assert androidPlugin.name instanceof String
  assert androidPlugin.path instanceof String
  def pluginDirectory = new File(androidPlugin.path, 'android')
  assert pluginDirectory.exists()
  include ":${androidPlugin.name}"
  project(":${androidPlugin.name}").projectDir = pluginDirectory
}

複製程式碼
  • 讀取Android檔案同級目錄下的.flutter-plugins-dependencies檔案
  • 讀取該檔案下的plugins欄位下的android陣列,對陣列的每個元素配置依賴。

提示: 是不是很熟悉? 沒錯iOSPod指令碼讀的就是這個檔案plugins欄位下的的ios欄位的值。

{
    "plugins":{
        "android":[
            ...
            {
                "name":"sqflite",
                "path":"/Users/*/Documents/flutter/.pub-cache/hosted/pub.dartlang.org/sqflite-1.3.2+3/",
                "dependencies":[

                ]
            }
            ...
        ],
        "ios":[...],
    }
}
複製程式碼

總結一下,最後settings.gradle大致生成的內容如下所示:

include ':app'

include ':fijkplayer'
project(":fijkplayer").projectDir = '/Users/*/Documents/flutter/.pub-cache/hosted/pub.dartlang.org/fijkplayer-0.8.7/android'

include ':shared_preferences'
project(":shared_preferences").projectDir = '/Users/*/Documents/flutter/.pub-cache/hosted/pub.dartlang.org/shared_preferences-0.5.12+4/android'

include ':sqflite'
project(":sqflite").projectDir = '/Users/*/Documents/flutter/.pub-cache/hosted/pub.dartlang.org/sqflite-1.3.2+3/android'

include ':url_launcher'
project(":url_launcher").projectDir = '/Users/*/Documents/flutter/.pub-cache/hosted/pub.dartlang.org/url_launcher-5.7.10/android'

複製程式碼

總結:settings.gradle中完成了所有依賴的module的配置。

Project / build.gradle

我們來看看Project / build.gradle中的一些設定:

// 1.
rootProject.buildDir = '../build'
subprojects {
    project.buildDir = "${rootProject.buildDir}/${project.name}"
}
// 2.
subprojects {
    project.evaluationDependsOn(':app')
}

複製程式碼
  1. 設定project子project的輸出路徑,路徑為和android同級的build資料夾下。

build directory

進去瞅瞅就可以驗證:

build files

你估計發現了,iOS的編譯結果也是放在這個目錄裡面的。

  1. 通過evaluationDependsOn定義了所有其他的moudule的配置都依賴於app這個moudule。即其他的所有moudule配置必須得等app這個moudule的配置完成後再進行配置。

總結:Project / build.gradle中配置了各個moudule的編譯輸出路徑和moudule間的依賴關係。

app / build.gradle

接下來我們來看下app / build.gradle中的內容:

// 1. 
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
    localPropertiesFile.withReader('UTF-8') { reader ->
        localProperties.load(reader)
    }
}

def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
    throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
}

def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
    flutterVersionCode = '1'
}

def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
    flutterVersionName = '1.0'
}

// 2.
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"

// 3.
flutter {
    source '../..'
}
複製程式碼

程式碼解釋:

  1. 第一大段的作用是從local.properties檔案中讀取版本編號和版本名稱,設定為Android App的版本編號和版本名稱;
  2. 根據從local.properties檔案中讀取到的$flutterRoot路徑匯入$flutterRoot/packages/flutter_tools/gradle/flutter.gradle的指令碼執行;
  3. flutter擴充套件的source屬性配置為.. / ..

flutter.gradle

flutter.gradle的目的是在Android 宿主App的編譯構建流程中執行一些Flutter相關的任務。

flutter.gradle中有兩個重要的類,一個是FlutterPlugin,一個是FlutterTask

flutter.gradle程式碼結構

FlutterPlugin

FlutterPlugin作為Gradle Plugin實現了Plugin介面,所以它的入口方法是apply()方法:

FlutterPlugin/apply
void apply(Project project) {
    ...
    // 1. 
    project.extensions.create("flutter", FlutterExtension)
    // 2
    project.afterEvaluate this.&addFlutterTasks
    // 3
    if (shouldSplitPerAbi()) {
        project.android {
            splits {
                abi {
                    enable true
                    reset()
                    universalApk false
                }
            }
        }
    }
    getTargetPlatforms().each { targetArch ->
        String abiValue = PLATFORM_ARCH_MAP[targetArch]
        project.android {
            if (shouldSplitPerAbi()) {
                splits {
                    abi {
                        include abiValue
                    }
                }
            }
        }
    }
    
    // 4
    String flutterRootPath = resolveProperty("flutter.sdk", System.env.FLUTTER_ROOT)
    if (flutterRootPath == null) {
        throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file or with a FLUTTER_ROOT environment variable.")
    }
    flutterRoot = project.file(flutterRootPath)
    if (!flutterRoot.isDirectory()) {
        throw new GradleException("flutter.sdk must point to the Flutter SDK directory")
    }

    engineVersion = useLocalEngine()
        ? "+" // Match any version since there's only one.
        : "1.0.0-" + Paths.get(flutterRoot.absolutePath, "bin", "internal", "engine.version").toFile().text.trim()

    String flutterExecutableName = Os.isFamily(Os.FAMILY_WINDOWS) ? "flutter.bat" : "flutter"
    flutterExecutable = Paths.get(flutterRoot.absolutePath, "bin", flutterExecutableName).toFile();

    // 5
    project.android.buildTypes {
        profile {
            initWith debug
            if (it.hasProperty("matchingFallbacks")) {
                matchingFallbacks = ["debug", "release"]
            }
        }
    }
    // 6
    if (shouldShrinkResources(project)) {
        String flutterProguardRules = Paths.get(flutterRoot.absolutePath, "packages", "flutter_tools",
                    "gradle", "flutter_proguard_rules.pro")
        project.android.buildTypes {
            release {
                shrinkResources isBuiltAsApp(project)
                proguardFiles project.android.getDefaultProguardFile("proguard-android.txt"), flutterProguardRules, "proguard-rules.pro"
            }
        }
    }
    // 7
    if (useLocalEngine()) {
        String engineOutPath = project.property('local-engine-out')
        File engineOut = project.file(engineOutPath)
        if (!engineOut.isDirectory()) {
            throw new GradleException('local-engine-out must point to a local engine build')
        }
        localEngine = engineOut.name
        localEngineSrcPath = engineOut.parentFile.parent
    }
    // 8
    project.android.buildTypes.each this.&addFlutterDependencies
    project.android.buildTypes.whenObjectAdded this.&addFlutterDependencies
}
複製程式碼

程式碼解釋:

  1. 建立一個FlutterExtension擴充套件,這個擴充套件有兩個屬性,source-Flutter APP工程的路徑,target- Flutter APP的執行入口,不設定就預設lib/main.dart
  2. app module的其他Task完成後執行addFlutterTasks方法;
  3. 決定是否開啟abi分包
  4. 獲取一些系統環境變數;
    • flutterRootPath --- /Users/*/Documents/flutter
    • flutterRoot --- /Users/*/Documents/flutter
    • engineVersion --- 1.0.0-2c956a31c0a3d350827aee6c56bb63337c5b4e6e
    • flutterExecutable --- flutter(mac), flutter.bat(windows)
  5. 預設有debugrelease兩個模式,這裡參照debug模式又新增了一個profile構建模式,所以現在變成了debug,releaseprofile三個構建模式;
  6. 是否啟動shrinkResources資源縮減;
  7. 是否設定本地maven倉庫;
  8. 給每個構建模式新增Flutter依賴addFlutterDependencies的呼叫。

接下來我們看看addFlutterDependencies中的實現:

FlutterPlugin/addFlutterDependencies
void addFlutterDependencies(buildType) {
    ...
    // 1. 
    String hostedRepository = System.env.FLUTTER_STORAGE_BASE_URL ?: DEFAULT_MAVEN_HOST
    String repository = useLocalEngine()
        ? project.property('local-engine-repo')
        : "$hostedRepository/download.flutter.io"
    project.rootProject.allprojects {
        repositories {
            maven {
                url repository
            }
        }
    }
    // 2
    addApiDependencies(project, buildType.name,
            "io.flutter:flutter_embedding_$flutterBuildMode:$engineVersion")
            print("io.flutter:flutter_embedding_$flutterBuildMode:$engineVersion\n");

    // 3
    List<String> platforms = getTargetPlatforms().collect()
    if (flutterBuildMode == "debug" && !useLocalEngine()) {
        platforms.add("android-x86")
        platforms.add("android-x64")
    }
    platforms.each { platform ->
        String arch = PLATFORM_ARCH_MAP[platform].replace("-", "_")
        addApiDependencies(project, buildType.name,
                "io.flutter:${arch}_$flutterBuildMode:$engineVersion")
                            print("io.flutter:${arch}_$flutterBuildMode:$engineVersion\n");
    }
}
複製程式碼

程式碼解釋:

  1. 設定maven倉庫的url地址,預設是storage.googleapis.com/download.fl…, 如果網速不太理想也可以配置FLUTTER_STORAGE_BASE_URL環境變數,讓其指向國內的映象地址**storage.flutter-io.cn/download.fl…
  2. 新增嵌入式的依賴io.flutter:flutter_embedding_$flutterBuildMode:$engineVersion, 這個依賴與構建模式Flutter Engine版本有關係。例子- io.flutter:flutter_embedding_debug:1.0.0-2c956a31c0a3d350827aee6c56bb63337c5b4e6e
  3. 新增libflutter.so依賴,這個依賴和架構Flutter Engine版本有關係。例子-io.flutter:armeabi_v7a_debug:1.0.0-2c956a31c0a3d350827aee6c56bb63337c5b4e6e

提示:

  1. flutter_embedding的作用是賦予Flutter嵌入native的能力;
  2. libflutter.so就是Flutter Engine;
  3. 這兩個依賴最後呼叫的是project.dependencies.add(configuration, dependency, config)這個方法,所以是給project加的依賴。因為sqflite等其他的module都需要這兩個依賴。

至此FlutterPlugin/apply的流程已經分析完了,接下來我們就來分析第2步遺留的addFlutterTasks方法。

FlutterPlugin/addFlutterTasks

這個方法的程式碼量比較大,我們來概括總結一下。

private void addFlutterTasks(Project project) {
    // 1
    String target = project.flutter.target
    if (target == null) {
        target = 'lib/main.dart'
    }
    // 2
    def addFlutterDeps = { variant -> 
        2.1 
        FlutterTask compileTask = project.tasks.create(name: taskName, type: FlutterTask) { ... }
        2.2
        Task packFlutterAppAotTask = project.tasks.create(name: "packLibs${FLUTTER_BUILD_PREFIX}${variant.name.capitalize()}", type: Jar) { ... }
        2.3
        addApiDependencies(project, variant.name, project.files {
            packFlutterAppAotTask
        })
        2.4 
        Task copyFlutterAssetsTask = project.tasks.create(name: "copyFlutterAssets${variant.name.capitalize()}",
                type: Copy,
            ) { ... }
    }
    // 3
    project.android.applicationVariants.all { variant -> ...}
    // 4
    configurePlugins()
}
複製程式碼
  1. gradle.properties獲取各種引數,上面給出的一個例子---如果沒有target配置,就預設設定為lib/main.dart
  2. 定義一個addFlutterDeps的函式.
  • 根據構建模式和第一步從gradle.properties獲取各種引數一起建立對應的FlutterTaskFlutterTask的功能則是編譯Flutter APP的程式碼。
  • packFlutterAppAotTask這個task是將FlutterTask的編譯結果打包成libs.jar檔案。
  • project上libs.jar的檔案依賴。
  • copyFlutterAssetsTask是進行Flutter App相關的asset進行拷貝。由於Flutter可能作為外掛編譯或子專案編譯,如果是外掛編譯產物編譯結果打包為AAR,子專案編譯則子專案編譯時,編譯結果被打包成APK,所以兩種情況下有區別處理。
  1. 為所有applicationVariantslibraryVariants新增Flutter依賴,執行addFlutterDeps函式然後把APK拷貝到目標路徑。
  2. 這個方法是給project新增Plugin的依賴.編譯方式不同依賴的處理方式也不一樣。
FlutterTask

FlutterTaskbuild()呼叫的是父類的buildBundle()方法:

void buildBundle() {
    if (!sourceDir.isDirectory()) {
        throw new GradleException("Invalid Flutter source directory: ${sourceDir}")
    }

    intermediateDir.mkdirs()

    // Compute the rule name for flutter assemble. To speed up builds that contain
    // multiple ABIs, the target name is used to communicate which ones are required
    // rather than the TargetPlatform. This allows multiple builds to share the same
    // cache.
    String[] ruleNames;
    if (buildMode == "debug") {
        if (fastStart) {
            ruleNames = ["faststart_android_application"]
        } else {
            ruleNames = ["debug_android_application"]
        }
    } else {
        ruleNames = targetPlatformValues.collect { "android_aot_bundle_${buildMode}_$it" }
    }
    project.exec {
        logging.captureStandardError LogLevel.ERROR
        executable flutterExecutable.absolutePath
        workingDir sourceDir
        if (localEngine != null) {
            args "--local-engine", localEngine
            args "--local-engine-src-path", localEngineSrcPath
        }
        if (verbose) {
            args "--verbose"
        } else {
            args "--quiet"
            }
        args "assemble"
        args "--depfile", "${intermediateDir}/flutter_build.d"
        args "--output", "${intermediateDir}"
        if (performanceMeasurementFile != null) {
            args "--performance-measurement-file=${performanceMeasurementFile}"
        }
        if (!fastStart || buildMode != "debug") {
            args "-dTargetFile=${targetPath}"
        } else {
            args "-dTargetFile=${Paths.get(flutterRoot.absolutePath, "examples", "splash", "lib", "main.dart")}"
        }
        args "-dTargetPlatform=android"
        args "-dBuildMode=${buildMode}"
        if (trackWidgetCreation != null) {
            args "-dTrackWidgetCreation=${trackWidgetCreation}"
        }
        if (splitDebugInfo != null) {
            args "-dSplitDebugInfo=${splitDebugInfo}"
        }
        if (treeShakeIcons == true) {
            args "-dTreeShakeIcons=true"
        }
        if (dartObfuscation == true) {
            args "-dDartObfuscation=true"
        }
        if (dartDefines != null) {
            args "--DartDefines=${dartDefines}"
        }
        if (bundleSkSLPath != null) {
            args "-iBundleSkSLPath=${bundleSkSLPath}"
        }
        if (codeSizeDirectory != null) {
            args "-dCodeSizeDirectory=${codeSizeDirectory}"
        }
        if (extraGenSnapshotOptions != null) {
            args "--ExtraGenSnapshotOptions=${extraGenSnapshotOptions}"
        }
        if (extraFrontEndOptions != null) {
            args "--ExtraFrontEndOptions=${extraFrontEndOptions}"
        }
        args ruleNames
    }
}
複製程式碼

這個方法其實是執行帶一些引數的flutter build方法。

APK

最後,我們來看看APK的結構。

  1. lib檔案中預設包含了4種架構的支援,裡面包括了Flutter Engine--- libflutter.soFlutter App程式碼---libapp.soFlutter外掛依賴的so檔案---libijkffmpeg.so,libijkplayer.so,libijksdl.so

你可能會遇到couldn't find "libflutter.so",因為x86裡面沒有libflutter.so和libapp.so,這是flutter的一個已知的問題。

  1. Flutter App中的資原始檔都打包到了assets/flutter_assets中。

APK

AndroidManifest.xml

我們來看看AndroidManifest.xml裡面都配置了些什麼東東:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.jj_movie">
    // 1.
    <application
        android:name="io.flutter.app.FlutterApplication"
        android:label="jj_movie"
        android:icon="@mipmap/ic_launcher">
        <activity
            ...>
            // 2
            <meta-data
              android:name="io.flutter.embedding.android.NormalTheme"
              android:resource="@style/NormalTheme"
              />
            // 3
            <meta-data
              android:name="io.flutter.embedding.android.SplashScreenDrawable"
              android:resource="@drawable/launch_background"
              />
            
        </activity>
        // 4
        <meta-data
            android:name="flutterEmbedding"
            android:value="2" />
    </application>
</manifest>
複製程式碼
  1. 專案的Application的類為io.flutter.app.FlutterApplication;
  2. 預設定義了一個白色的主題,在Flutter APP載入顯示前使用者可見,在Flutter APP載入顯示後作為Window的背景;
  3. 可以修改啟動圖;
  4. 使用Flutter Android Embedding V2 版本。FlutterActivity,FlutterActivity,FlutterActivityFlutterActivity等類都是在V2版本中引入的。

FlutterApplication

FlutterApplication中的程式碼很簡單,就主要執行了FlutterInjector.instance().flutterLoader().startInitialization(this)這行程式碼。

public class FlutterApplication extends Application {
  @Override
  @CallSuper
  public void onCreate() {
    super.onCreate();
    FlutterInjector.instance().flutterLoader().startInitialization(this);
  }
}
複製程式碼

FlutterLoader主要的作用就是載入FLutter Engine 和載入Flutter APP的資源等。我們看看FlutterLoaderstartInitialization方法中的主要程式碼:

public void startInitialization(@NonNull Context applicationContext, @NonNull Settings settings) {
    // 1.
    if (Looper.myLooper() != Looper.getMainLooper()) {
      throw new IllegalStateException("startInitialization must be called on the main thread");
    }

    // 2.
    VsyncWaiter.getInstance((WindowManager) appContext.getSystemService(Context.WINDOW_SERVICE))
        .init();

    // 3.
    Callable<InitResult> initTask =
        new Callable<InitResult>() {
          @Override
          public InitResult call() {
            ...
          }
        };
  }
複製程式碼
  1. 確保本方法是在主執行緒執行;
  2. VsyncWaiter進行初始化,它的主要作用是在Android系統註冊並等待VSync訊號。

VsyncWaiterFlutter渲染的中繼者,當收到訊號後,會通知Flutter app發起渲染呼叫,然後執行一些列的layout和paint,最後提交給GPU執行緒合成上屏。

  1. 開啟一個非同步執行緒,載入一些asset資源。

總結:FlutterLoader執行startInitialization是為Flutter app的 載入和渲染做好準備工作。

FlutterActivity

我們來看看FlutterActivity的重要程式碼:

public class FlutterActivity extends Activity {
    // 1
    protected FlutterActivityAndFragmentDelegate delegate;
    // 2
    private LifecycleRegistry lifecycle;

    public FlutterActivity() {
        lifecycle = new LifecycleRegistry(this);
    }
    
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        // 3.1
        switchLaunchThemeForNormalTheme();
    
        super.onCreate(savedInstanceState);

        // 3.2
        delegate = new FlutterActivityAndFragmentDelegate(this);
        delegate.onAttach(this);
        delegate.onActivityCreated(savedInstanceState);
        
        // 3.3
        configureWindowForTransparency();
        // 3.4
        setContentView(createFlutterView());
        // 3.5
        configureStatusBarForFullscreenFlutterExperience();
    }
    
    private View createFlutterView() {
    return delegate.onCreateView(null , null, null);
  }
}
複製程式碼
  1. FlutterActivityAndFragmentDelegate屬性delegate可以說是負責處理FLutter app相關的絕大部分功能的物件;
  2. LifecycleRegistry是一個LifeCycle,處理FlutterActivity生命週期的事情,在建構函式中初始化;
  3. onCreate方法中主要的工作是:
    • 先切換到啟動圖的主題顯示啟動圖片
    • 初始化FlutterActivityAndFragmentDelegate物件delegate
    • 然後將window的背景設定透明
    • FlutterActivityView新增由delegate物件建立的一個FlutterView,作為FLutter app的渲染ViewFlutterViewSurfaceView的子類。
    • Android 5.0以上設定為沉浸式狀態列

到此為止,一切都已經準備就緒了,就等FlutterActivityAndFragmentDelegate將內容載入進來了。

FlutterActivityAndFragmentDelegate

  1. 建構函式傳入FlutterActivity作為Host, 主要就是為了獲取context
FlutterActivityAndFragmentDelegate(@NonNull Host host) {
    this.host = host;
  }
複製程式碼
  1. onAttach主要是初始化了Flutter Engine和註冊了Flutter外掛;
void onAttach(@NonNull Context context) {
    ensureAlive();

    if (flutterEngine == null) {
      setupFlutterEngine();
    }
    
    host.configureFlutterEngine(flutterEngine);
  }
複製程式碼
// FlutterActivity
public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) {
    GeneratedPluginRegister.registerGeneratedPlugins(flutterEngine);
 }
複製程式碼

GeneratedPluginRegistrant這個註冊外掛的邏輯和iOS的類似。

  1. 載入Flutter App的內容

FlutterActivity執行onStart時會呼叫FlutterActivityAndFragmentDelegateonStart方法,然後就從lib/main.dart入口檔案開始執行了。

void onStart() {
    doInitialFlutterViewRun();
}

private void doInitialFlutterViewRun() {
    
    if (flutterEngine.getDartExecutor().isExecutingDart()) {
      return;
    }

    if (host.getInitialRoute() != null) {
      flutterEngine.getNavigationChannel().setInitialRoute(host.getInitialRoute());
    }

    String appBundlePathOverride = host.getAppBundlePath();
    if (appBundlePathOverride == null || appBundlePathOverride.isEmpty()) {
      appBundlePathOverride = FlutterInjector.instance().flutterLoader().findAppBundlePath();
    }

    DartExecutor.DartEntrypoint entrypoint =
        new DartExecutor.DartEntrypoint(
            appBundlePathOverride, host.getDartEntrypointFunctionName());
    flutterEngine.getDartExecutor().executeDartEntrypoint(entrypoint);
  }
複製程式碼

相關文章