【Compose Multiplatform 跨端开发学与练】第7课 平台适配与互操作
本课目标:理解 expect/actual 的编译时契约本质,掌握接口注入作为更优替代方案的判断标准,学会 Compose 与 SwiftUI 的双向互操作技术,建立“平台差异分层决策”的系统方法论。
系列整体规划
| 课次 | 主题 | 核心内容 | 难度 |
|---|---|---|---|
| 第1课 | 从零开始 | 技术概览、环境搭建、第一个应用、代码解读 | ⭐ |
| 第2课 | Compose 基础语法 | @Composable、状态管理、重组机制、Modifier 体系 | ⭐⭐ |
| 第3课 | 布局与组件 | Column/Row/Box、LazyColumn、Material3 组件库 | ⭐⭐ |
| 第4课 | 导航与路由 | Navigation Compose、类型安全路由、深层链接 | ⭐⭐⭐ |
| 第5课 | 网络与数据层 | Ktor 客户端、序列化、Repository 模式 | ⭐⭐⭐ |
| 第6课 | 状态管理与架构 | ViewModel、单向数据流、依赖注入 | ⭐⭐⭐⭐ |
| 第7课 | 平台适配与互操作 | expect/actual、SwiftUI 互操作、平台特定 API | ⭐⭐⭐⭐ |
| 第8课 | 资源管理与主题 | 多平台资源、图片加载、深浅色主题 | ⭐⭐⭐ |
| 第9课 | 测试与调试 | Compose UI 测试、单元测试、性能分析 | ⭐⭐⭐⭐ |
| 第10课 | 发布与部署 | Android/iOS/桌面/Web 打包发布、CI/CD | ⭐⭐⭐⭐⭐ |
第7课 平台适配与互操作
一、expect/actual:编译时契约机制
1.1 从“运行时分支”到“编译时契约”
如果你有前端开发经验,一定熟悉条件编译的痛点。在 JavaScript 中,平台差异通常用运行时判断来处理:
// 运行时分支:类型系统无法验证完整性
function getUUID() {
if (Platform.OS === 'web') {
return crypto.randomUUID();
} else {
return require('uuid').v4();
}
}
这种方式的根本问题是:类型系统无法验证每个平台的实现是否齐全。 如果你遗漏了某个平台的分支,编译不会报错,只有到了运行时才会暴露问题。
Kotlin Multiplatform 的 expect/actual 给出了一个完全不同的答案:编译时强制实现。
// commonMain:声明契约,不包含实现
expect fun generateUUID(): String
// androidMain:提供平台实现
actual fun generateUUID(): String = java.util.UUID.randomUUID().toString()
// iosMain:提供平台实现
actual fun generateUUID(): String = platform.Foundation.NSUUID().UUIDString()
关键差异在于:如果你在 commonMain 写了 expect,但某个平台忘记写 actual,编译器直接报错。 这不是运行时的防御性检查,而是编译期的硬性约束。
1.2 三种声明形式
expect/actual 支持多种声明形式,选择取决于使用场景:
函数级别(最推荐) 粒度最细,最容易测试和维护:
// commonMain
expect fun getPlatformName(): String
// androidMain
actual fun getPlatformName(): String = "Android"
// iosMain
actual fun getPlatformName(): String = "iOS"
对象级别 适合需要单例的场景,比如日志工具:
// commonMain
expect object AppLogger {
fun d(message: String)
fun e(message: String, throwable: Throwable? = null)
}
// androidMain
actual object AppLogger {
actual fun d(message: String) { Log.d("App", message) }
actual fun e(message: String, throwable: Throwable?) { /* ... */ }
}
类级别 只在特殊场景下使用,比如需要继承平台已有的基类时。但 Kotlin 官方明确指出:expect/actual 类处于 Beta 状态,且在简单场景下不推荐使用。
1.3 接口注入:比 expect/actual 更好的选择
Kotlin 官方文档给出了一个容易被忽视的建议:在大多数情况下,应该优先使用普通的 Kotlin 接口,而不是 expect/actual。
// commonMain:定义接口
interface Platform {
val name: String
}
// androidMain:提供实现
class AndroidPlatform : Platform {
override val name: String = "Android ${Build.VERSION.SDK_INT}"
}
// iosMain:提供实现
class IOSPlatform : Platform {
override val name: String = "iOS ${UIDevice.currentDevice.systemVersion}"
}
为什么接口优于 expect/actual 类?接口不限制每个平台只能有一个实现。 你可以在测试中注入假实现,可以在同一平台上提供多个实现,可以更容易地做依赖注入。
判断标准:如果平台差异是一组行为(函数、属性),用 expect/actual 函数。如果平台差异是一组能力(接口的实现),用接口 + 依赖注入。Koin 官方文档同样建议:对于需要测试的业务逻辑,优先使用接口而非 expect 类。
1.4 WASM 平台的特殊处理
在 Web(Wasm)平台上,访问 JavaScript API 需要特殊声明:
// wasmJsMain
@JsFun("() => 'WASM (Web)'")
external fun getWasmPlatformName(): String
actual fun getPlatformName(): String = getWasmPlatformName()
WASM 平台不支持 js() 内联函数,需要通过 external 声明 + @JsFun 注解来调用 JS 原生 API。
1.5 状态栏适配:expect/actual 的 Composable 应用
平台特定的 UI 行为同样可以用 expect/actual 处理。比如状态栏样式:
// commonMain
@Composable
expect fun PlatformStatusBar(darkIcons: Boolean)
// androidMain
@Composable
actual fun PlatformStatusBar(darkIcons: Boolean) {
val systemUiController = rememberSystemUiController()
SideEffect {
systemUiController.setStatusBarColor(Color.Transparent, darkIcons)
}
}
Android 侧需要将平台副作用放在 SideEffect {} 中,确保只在组合提交后执行,避免重组期间触发。iOS 侧通常依赖 UIKit 互操作或 Info.plist 配置,不需要在 Compose 层强行模拟。
二、SwiftUI 互操作
2.1 双向嵌入的整体架构
CMP 与 SwiftUI 的互操作是双向的:你可以把 Compose 嵌入 SwiftUI 应用,也可以把 SwiftUI 嵌入 Compose 界面。 这为渐进式迁移和混合开发提供了基础。
理解这个架构的关键是:Compose 和 SwiftUI 之间的桥梁是一个 UIViewController。 Compose 侧的 ComposeUIViewController 产出 UIViewController,SwiftUI 侧用 UIViewControllerRepresentable 包装它。反过来,SwiftUI 的视图被 UIHostingController 包装成 UIViewController,传递给 Compose 侧的 UIKitViewController。
2.2 Compose 嵌入 SwiftUI
在 SwiftUI 应用中使用 Compose,Kotlin 侧创建一个返回 UIViewController 的函数:
// iosMain
fun MainViewController(): UIViewController = ComposeUIViewController {
MaterialTheme {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("This is Compose code", fontSize = 20.sp)
}
}
}
Swift 侧用 UIViewControllerRepresentable 包装:
import SwiftUI
import ComposeApp
struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
return Main_iosKt.MainViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
struct ContentView: View {
var body: some View {
VStack {
Text("SwiftUI Header")
ComposeView().frame(height: 300)
}
}
}
一个必须注意的配置:CMP 渲染需要显式启用高刷新率。在 iOS 应用的 Info.plist 中添加 CADisableMinimumFrameDurationOnPhone 键,否则应用会在运行时崩溃。
2.3 SwiftUI 嵌入 Compose:工厂模式
反向嵌入需要 Kotlin 侧接受一个 () -> UIViewController 的工厂函数。JetBrains 官方示例推荐使用 CompositionLocal + 工厂接口 的模式,让共享代码声明意图,平台侧提供实现。
Kotlin 侧定义工厂接口和 CompositionLocal:
// commonMain(或 iosMain)
interface NativeViewFactory {
fun createPaymentButton(onPaid: () -> Unit): UIViewController
}
val LocalNativeViewFactory = staticCompositionLocalOf<NativeViewFactory> {
error("NativeViewFactory not provided")
}
// iOS 入口提供工厂
fun MainViewController(factory: NativeViewFactory): UIViewController =
ComposeUIViewController {
CompositionLocalProvider(LocalNativeViewFactory provides factory) {
App()
}
}
共享 UI 中声明原生组件的位置:
@Composable
fun PaymentScreen() {
val factory = LocalNativeViewFactory.current
Column {
Text("选择支付方式")
UIKitViewController(
factory = { factory.createPaymentButton(onPaid = { /* ... */ }) },
modifier = Modifier.fillMaxWidth().height(56.dp)
)
}
}
Swift 侧实现工厂:
class IOSNativeViewFactory: NativeViewFactory {
func createPaymentButton(onPaid: @escaping () -> Void) -> UIViewController {
let button = PaymentButton(onPaid: onPaid)
return UIHostingController(rootView: button)
}
}
这个模式的优雅之处在于:共享代码(commonMain)完全不知道 UIKit 的存在,它只声明“这里需要一个支付按钮”。平台实现通过依赖注入提供。
2.4 典型应用场景
SwiftUI 嵌入 Compose 的典型场景是使用平台专属的 UI 组件。以下是一些真实场景:
- 地图:Apple MapKit,不是 Web 嵌入
- WebView:WKWebView,真正的原生网页渲染
- 相机:AVCaptureSession
- 支付:Stripe 的支付表单,只有 UIKit/SwiftUI 版本,无法用 Compose 重新实现
以地图为例:
Main_iosKt.ComposeEntryPointWithUIViewController(
createUIViewController: {
let region = Binding.constant(
MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
)
)
let mapView = Map(coordinateRegion: region)
return UIHostingController(rootView: mapView)
}
)
2.5 视频播放器的互操作策略选择
KINTO 技术博客分享了一个真实案例:在 CMP 中实现视频播放器时,需要选择SurfaceView(零拷贝、支持 DRM)还是TextureView(支持圆角裁剪和变换)。
他们的结论是:选择权本身就是价值。 在同一个应用中,角圆预览卡使用 TextureView(为了保持设计的圆角不被 Square 视频突破),全屏播放使用 SurfaceView(为了性能和 DRM 支持)。
这个案例的启示是:平台互操作不只是“能用”,还要考虑视觉保真度与性能的权衡。CMP 的互操作 API 提供了这种细粒度控制的能力。
2.6 原生文本输入:iOS 体验的关键拼图
CMP 1.11.0 引入了基于 UIView 的原生文本输入实现(实验性)。这听起来像是一个小改进,但对 iOS 应用的实际体验影响巨大。
原生文本输入带来的改进包括:精确的光标移动、原生手势和选择手柄、系统上下文菜单(包括自动填充、翻译、搜索)。
为什么这很重要?文本输入是手机上最常被触摸的交互。 如果跨平台工具包在这件事上“露怯”,用户不会报 bug 说“缺少放大镜”,他们只是感觉应用“不对劲”,然后降低对应用的信任。自动填充尤其关键:如果登录或结账表单不能调出保存的凭证或短信验证码,转化率会受影响。
如果你想在 iOS 上获得最原生的体验,可以在特性分支上测试这个功能,在登录、搜索、表单等输入密集的屏幕上启用它。原有的跨平台文本输入仍然是稳定的默认选项。
2.7 混合导航架构
在真实的混合应用中,导航需要跨越 Compose 和 SwiftUI 两个世界。一个实用的模式是路由常量 + 平台桥接:
// shared:定义跨平台路由
object Routes {
const val LOGIN = "login"
const val SCREEN_A = "screen_a"
const val SETTINGS = "settings"
}
// iosMain:根据初始路由决定显示哪个 Compose 页面
fun createComposeViewController(initialRoute: String): UIViewController {
return ComposeUIViewController {
when (initialRoute) {
Routes.LOGIN -> LoginScreen()
Routes.SCREEN_A -> ScreenA()
else -> DefaultScreen()
}
}
}
SwiftUI 侧用 NavigationStack 管理导航路径,将 Compose 的 UIViewController 作为目的地嵌入。这种模式让原生导航效果(如 iOS 的返回手势和转场动画)和 Compose 内容可以共存。
三、平台差异的分层决策框架
经过本课的学习,你可以建立一个清晰的决策框架:
第一层:多平台库优先。 如果 Ktor、SQLDelight、Koin 这些库已经提供了跨平台实现,直接使用,不需要平台适配。
第二层:接口 + DI。 如果平台差异是一组能力(需要注入不同的实现),用普通接口定义契约,通过 DI 容器注入平台实现。
第三层:expect/actual 函数。 如果平台差异是一组行为,且每个平台只需要一个实现,用 expect/actual 函数。这是最轻量的方案。
第四层:expect/actual 类(谨慎使用)。 只在需要继承平台基类时使用,且要接受 Beta 状态的风险。
第五层:原生 UI 互操作。 如果平台差异涉及UI 组件(地图、相机、支付表单),用 UIKitView/UIKitViewController(iOS)或 AndroidView(Android)嵌入原生视图。
一个实用原则:尽可能多的代码放在 commonMain,只在必要时使用 expect/actual 在平台特定源集中实现差异。
四、习题与参考答案
本课习题分为三类:概念理解(1-5 题)、代码实践(6-11 题)、综合设计(12-15 题)。
概念理解
习题 1:expect/actual 与运行时分支的区别
题目:前端开发中常用运行时条件判断处理平台差异,Kotlin 的 expect/actual 有什么本质不同?
参考答案:运行时分支在打包或运行时判断,类型系统无法验证完整性,遗漏平台实现要等到运行时才发现。expect/actual 是编译时契约,编译器强制每个目标平台提供 actual 实现,遗漏会直接编译失败。
习题 2:接口 vs expect/actual 类
题目:Kotlin 官方建议优先使用接口而非 expect/actual 类,为什么?
参考答案:接口不限制每个平台只能有一个实现,测试时可以注入假实现,同一平台可以有多套实现。expect/actual 类处于 Beta 状态,且把设计限制为“每个平台一个实现”,灵活性更低。
习题 3:Compose 与 SwiftUI 的桥梁
题目:Compose 和 SwiftUI 之间的桥梁是什么?为什么用这个类型作为桥梁?
参考答案:桥梁是 UIViewController。Compose 的 ComposeUIViewController 产出 UIViewController,SwiftUI 用 UIViewControllerRepresentable 包装它。反过来,SwiftUI 视图用 UIHostingController 包装成 UIViewController 传给 Compose。UIViewController 是 iOS 上最通用的视图容器类型,两个框架都能与之互操作。
习题 4:为什么 SwiftUI 不能直接在 Kotlin 中编写
题目:为什么在 Compose 中嵌入 SwiftUI 必须通过工厂函数传递,而不能直接在 Kotlin 中写 SwiftUI 代码?
参考答案:Kotlin/Native 只能调用 Swift/Objective-C 的编译产物,不能“生成”Swift 代码。SwiftUI 的声明式语法是 Swift 编译器的特性,Kotlin 编译器无法解析。所以必须在 Swift 中写好 SwiftUI 视图,包装成 UIViewController,再作为工厂函数传递给 Kotlin。
习题 5:原生文本输入为什么重要
题目:CMP 1.11.0 的原生文本输入为什么被视为 iOS 体验的关键改进?
参考答案:文本输入是手机上最常被触摸的交互。跨平台工具包如果在这件事上表现不佳,用户不会报 bug,而是感觉应用“不对劲”并降低信任。自动填充尤其关键:如果登录或结账表单不能调出保存的凭证,转化率会受影响。基于 UIView 的原生实现让 Compose 应用继承了系统行为,而不是重新实现一个“差不多”的版本。
代码实践
习题 6:实现平台名称获取
题目:用 expect/actual 实现一个 getPlatformName(): String 函数,Android 返回 “Android”,iOS 返回 “iOS”,桌面端返回 “Desktop”,Web 返回 “Web”。
参考答案:
// commonMain
expect fun getPlatformName(): String
// androidMain
actual fun getPlatformName(): String = "Android"
// iosMain
actual fun getPlatformName(): String = "iOS"
// desktopMain
actual fun getPlatformName(): String = "Desktop"
// wasmJsMain
@JsFun("() => 'Web'")
external fun getWebPlatformName(): String
actual fun getPlatformName(): String = getWebPlatformName()
习题 7:用接口替代 expect/actual 类
题目:把习题 6 的 expect/actual 函数改为接口 + 工厂函数的方式。
参考答案:
// commonMain
interface Platform { val name: String }
expect fun createPlatform(): Platform
// androidMain
class AndroidPlatform : Platform {
override val name: String = "Android"
}
actual fun createPlatform(): Platform = AndroidPlatform()
// iosMain
class IOSPlatform : Platform {
override val name: String = "iOS"
}
actual fun createPlatform(): Platform = IOSPlatform()
延伸思考:这种模式比直接 expect/actual 类更灵活。你可以在测试中创建一个 TestPlatform,在 DI 容器中替换实现,而不需要为每个平台提供 actual 类。
习题 8:统一日志接口
题目:用 expect/actual 对象实现一个 AppLogger,Android 用 android.util.Log,iOS 用 NSLog,桌面端用 println。
参考答案:
// commonMain
expect object AppLogger {
fun d(message: String)
fun e(message: String, throwable: Throwable? = null)
}
// androidMain
actual object AppLogger {
actual fun d(message: String) { android.util.Log.d("App", message) }
actual fun e(message: String, throwable: Throwable?) {
android.util.Log.e("App", message, throwable)
}
}
// iosMain
actual object AppLogger {
actual fun d(message: String) { platform.Foundation.NSLog("DEBUG: $message") }
actual fun e(message: String, throwable: Throwable?) {
platform.Foundation.NSLog("ERROR: $message, cause: $throwable")
}
}
// desktopMain
actual object AppLogger {
actual fun d(message: String) { println("DEBUG: $message") }
actual fun e(message: String, throwable: Throwable?) { println("ERROR: $message, $throwable") }
}
习题 9:生成 UUID
题目:实现跨平台 UUID 生成函数。
参考答案:
// commonMain
expect fun randomUUID(): String
// androidMain
actual fun randomUUID(): String = java.util.UUID.randomUUID().toString()
// iosMain
actual fun randomUUID(): String = platform.Foundation.NSUUID().UUIDString()
// desktopMain
actual fun randomUUID(): String = java.util.UUID.randomUUID().toString()
习题 10:Compose 嵌入 SwiftUI 的 Kotlin 侧(工厂模式)
题目:用工厂模式实现 Kotlin 侧的入口函数,接受一个 NativeViewFactory 并在 Compose 中展示。
参考答案:
// commonMain
interface NativeViewFactory {
fun createStarView(): UIViewController
}
val LocalNativeViewFactory = staticCompositionLocalOf<NativeViewFactory> {
error("NativeViewFactory not provided")
}
// iosMain
fun MainViewController(factory: NativeViewFactory): UIViewController =
ComposeUIViewController {
CompositionLocalProvider(LocalNativeViewFactory provides factory) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Compose 中的原生组件")
val nativeFactory = LocalNativeViewFactory.current
UIKitViewController(
factory = { nativeFactory.createStarView() },
modifier = Modifier.size(200.dp).border(1.dp, Color.Gray)
)
}
}
}
习题 11:Swift 侧实现工厂
题目:编写 Swift 代码,实现习题 10 的 NativeViewFactory,创建一个包含星形图标的 SwiftUI 视图。
参考答案:
import SwiftUI
import ComposeApp
class IOSNativeViewFactory: NativeViewFactory {
func createStarView() -> UIViewController {
let view = VStack(spacing: 16) {
Text("原生 SwiftUI 组件")
.font(.headline)
Image(systemName: "star.fill")
.foregroundColor(.yellow)
.font(.largeTitle)
}
return UIHostingController(rootView: view)
}
}
综合设计
习题 12:带平台标识的首页
题目:实现一个首页,顶部显示平台名称,中间是共享的 Compose UI。用接口注入的方式提供平台名称。
参考答案:
// commonMain
interface Platform { val name: String }
@Composable
fun App(platform: Platform) {
MaterialTheme {
Column(
modifier = Modifier.fillMaxSize().safeContentPadding(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "运行平台: ${platform.name}",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(24.dp)
)
Text("这是共享的 Compose UI")
}
}
}
// 各平台入口分别创建 Platform 实例并传入
// Android: App(AndroidPlatform())
// iOS: App(IOSPlatform())
// Desktop: App(DesktopPlatform())
习题 13:平台专属文件存储路径
题目:用 expect/actual 实现一个 getCacheDirectory(): String,Android 返回 context.cacheDir.path,iOS 返回 NSCachesDirectory 路径,桌面端返回 System.getProperty("java.io.tmpdir")。
参考答案:
// commonMain
expect fun getCacheDirectory(): String
// androidMain
actual fun getCacheDirectory(): String {
return androidContext.cacheDir.path
}
// iosMain
actual fun getCacheDirectory(): String {
val paths = NSSearchPathForDirectoriesInDomains(
NSCachesDirectory, NSUserDomainMask, true
)
return paths.first() as String
}
// desktopMain
actual fun getCacheDirectory(): String = System.getProperty("java.io.tmpdir")
延伸思考:Android 的 Context 不能直接放在 androidMain 的顶层。通常通过 Application 单例或 DI 容器注入。Koin 文档推荐使用 ContextWrapper 模式:在 commonMain 定义 interface AppContext,Android 侧用 AndroidAppContext(context) 实现,iOS 侧用空实现。
习题 14:混合导航中的路由分发
题目:实现一个 createComposeViewController(route: String) 函数,根据路由字符串返回不同的 Compose 页面,供 SwiftUI 导航使用。
参考答案:
// iosMain
fun createComposeViewController(route: String): UIViewController {
return ComposeUIViewController {
when (route) {
"login" -> LoginScreen()
"profile" -> ProfileScreen()
"settings" -> SettingsScreen()
else -> Text("未知路由: $route")
}
}
}
习题 15:平台特定的分享功能
题目:实现一个 ShareManager,Android 用 Intent.ACTION_SEND,iOS 用 UIActivityViewController,桌面端用剪贴板复制。在 Compose UI 中提供一个分享按钮。
参考答案:
// commonMain
interface ShareManager {
fun share(text: String)
}
@Composable
expect fun rememberShareManager(): ShareManager
// androidMain
class AndroidShareManager(private val context: Context) : ShareManager {
override fun share(text: String) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, text)
}
context.startActivity(Intent.createChooser(intent, "分享"))
}
}
@Composable
actual fun rememberShareManager(): ShareManager {
val context = LocalContext.current
return remember(context) { AndroidShareManager(context) }
}
// iosMain
class IosShareManager : ShareManager {
override fun share(text: String) {
val activityVC = UIActivityViewController(
activityItems = listOf(text),
applicationActivities = null
)
// 获取当前 UIViewController 并 present
}
}
@Composable
actual fun rememberShareManager(): ShareManager = remember { IosShareManager() }
使用方式:
@Composable
fun ShareButton(content: String) {
val shareManager = rememberShareManager()
Button(onClick = { shareManager.share(content) }) {
Text("分享")
}
}
五、本课小结
expect/actual 的本质:编译时契约机制。commonMain 声明 expect,各平台提供 actual,编译器强制全覆盖。与运行时分支相比,遗漏平台实现会在编译期暴露,而非运行时。
接口优于 expect/actual 类:接口不限制每个平台只有一个实现,测试时可注入假实现。expect/actual 类处于 Beta 状态,适合需要继承平台基类的特殊场景。
SwiftUI 双向互操作:桥梁是 UIViewController。Compose 嵌入 SwiftUI 用 UIViewControllerRepresentable,SwiftUI 嵌入 Compose 用 UIKitViewController + UIHostingController。工厂模式(CompositionLocal + 接口)让共享代码声明意图,平台侧提供实现。
原生文本输入的重要性:CMP 1.11.0 的 UIView 原生文本输入(实验性)解决了 iOS 上最影响体验的交互细节——光标、手势、上下文菜单、自动填充。
分层决策框架:多平台库 > 接口 + DI > expect/actual 函数 > expect/actual 类 > 原生 UI 互操作。优先级从左到右递减,越靠左越简单、越可测试。
六、下一课预告
第8课 资源管理与主题
更多推荐




所有评论(0)