3757 字
19 分钟
提升APP冷启动速度-iOS篇
2026-02-10

最近Ham的冷启动速度真的是越来越慢了,慢到令人发指。从手指点击APP Icon到首个页面出现,居然需要3.5秒,是时候要好好优化下了!

工具#

Xcode贴心地为我们准备好了耗时排查工具App Launch

我们选择好要测量的APP和超时时间后,就可以点击左上角的按钮开始抓trace啦~

测量#

设备信息:

  • iPhone 13

  • 系统 iOS 26.0.2

启动过程:强杀APP并打开多个其他APP,避免dyld缓存优化启动速度。

细看Progress部分,APP的启动过程可以分为三个部分:

初始化阶段T1#

包含:

  • Progress Creation 系统创建进程
  • System Interface Initialization 系统接口初始化,此时dyld会解析动态符号

启动阶段T2#

包含:

  • UIKit Initialization - UIKit初始化,不可规避
  • willFinishLaunchingWithOptions() - AppDelegate里的委托方法
  • didFinishLaunchingWithOptions() - AppDelegate里的委托方法
  • sceneWillConnectTo()
  • sceneWillEnterForeground()
  • Initial Frame Rendering - 首帧渲染

前台阶段T3#

APP初始化完成,用户看到APP第一帧。


当然,由系统的特性我们可以知道,因为第一帧是主线程绘制的,要优化冷启动时间,就必须要让主线程干更少的活。于是,我们看trace的时候,可以把目光瞄准在主线程上。

分析#

通过分析trace,我们可以知道,占APP启动时间大头,并且可优化的是:

  • Initial Frame Rendering - 2.99s

  • System Interface Initialization - 757.31 ms

  • willFinishLaunchingWithOptions() - 115.47ms

  • didFinishLaunchingWithOptions() - 94.85ms

Initial Frame Rendering优化#

我们观察下这段trace:

😯,居然有View在主线程开Database

日程卡优化#

先来说下背景。Ham启动成功后,会进入“状态”tab,里面展示很多实时通知的状态卡片,比如天气、课程、校车、日程等:

Ham里带日程功能,日程数据存在Realm数据库里。 StatusScheduleCard 是一张状态卡片,用来展示用户的日程情况(见上图)。StatusScheduleCard为什么会卡主线程?我们看看代码:

import SwiftUI
import RealmSwift
struct StatusScheduleCard: View {
@ObservedResults(ScheduleItemModel.self,
where: {
let now = Date()
return ($0.end == nil && $0.begin >= now) ||
($0.end != nil && $0.end >= now)
},
sortDescriptor: SortDescriptor(keyPath: "begin", ascending: true)) var scheduleItemList
...
var body: some View {
...
ForEach(1..<5) { i in
if i < scheduleList.count {
let scheduleModel = scheduleList[i]
scheduleItemView(scheduleItem: scheduleModel)
}
}
...
}

结合trace,这下我们明白了:

APP在渲染第一帧时会创造根View的struct,而因为日程卡要立刻上屏,因此也会初始化StatusScheduleCardStatusScheduleCard 里有一个属性scheduleItemList,被ObservedResults wrap了。

StatusScheduleCard上屏时需要获取前五项日程数据,此时访问scheduleList[i],实际会触发Realm数据库的初始化。在渲染View时初始化数据库,当然会卡啦😭

当然,这个问题不能怪开发者,只能说明是框架本身的缺陷:开发者也不知道这个@ObservedResults的lazy fetch会在主线程读取数据库啊😭😭

怎么解决呢:

  1. 延时加载日程卡。在T3阶段后再加载+展示。
  2. 放弃使用@ObservedResults,使用传统方式+线程切换打开Realm获取数据。
StatusScheduleCard.swift
struct StatusScheduleCard: View {
@ObservedObject var vm: StatusScheduleCardViewModel
@ViewBuilder
var body: some View {
if vm.inited {
StatusScheduleCardInner(vm: vm)
}
}
}
StatusScheduleCardViewModel.swift
@MainActor
class StatusScheduleCardViewModel: ObservableObject, StatusCardViewModel {
@Published var inited = false
...
func onInit() {
inited = true
initUpdateTask()
}
private func initUpdateTask() {
Task {
while !Task.isCancelled {
updateData()
do {
try await Task.sleep(nanoseconds: 10_000_000_000)
} catch {
break
}
}
}
}
private func updateData() {
currentTime = Date()
let currentWeekInfo = getCurrentWeekInfo(date: currentTime)
Task.detached(priority: .background) { [currentWeekInfo, currentTime] in
let realm = try! Realm(queue: nil)
let results = realm.objects(ScheduleItemModel.self)
.where {
($0.end == nil && $0.begin >= currentTime) ||
($0.end != nil && $0.end >= currentTime)
}
.freeze()
.sorted(byKeyPath: "begin", ascending: true)
let weekScheduleListResult = results
.where {
return ($0.end == nil && $0.begin <= currentWeekInfo.end) || ($0.end != nil && $0.end <= currentWeekInfo.end)
}
.freeze()
await MainActor.run {
let snapshot = Array(results)
let weekScheduleList = Array(weekScheduleListResult)
withAnimation {
self.scheduleList = snapshot
self.weekScheduleList = weekScheduleList
}
}
}
}
...

校巴卡优化#

除了日程卡,校巴卡也占据了很多时间,初始化的时候居然在创建WebView!

这里再说下背景。BusView是校巴的二级H5页。按道理来说这里不应该直接初始化才对?🤔

真正原因其实在CommonStatusCard上:

CommonStatusCard.swift
struct CommonStatusCard<Content: View>: View {
let icon: String
let title: String
let color: Color
let padding: CGFloat
var navDest: AnyView? = nil
let content: () -> Content
init<NavView: View>(icon: String,
title: String,
color: Color,
padding: Int = 12,
navDest: () -> NavView,
content: @escaping () -> Content) {
self.icon = icon
self.title = title
self.color = color
self.navDest = AnyView(navDest())
self.content = content
self.padding = CGFloat(padding)
}

CommonStatusView在初始化时会直接执行navDest,并保存在AnyView里。所以,CommonStatusView初始化时,会初始化二级页里的内容。本质上说,是CommonStatusView编码不合理引起了这个问题。

最佳的解决方法是,CommonStatusView里就不该使用AnyView,应使用ViewBuilder去存View。后期Ham的导航架构从NavigationView迁移至NavigationStack,这样navDest里就不用真传一个ViewBuilder进来,只用传一个Route就好了,也就没有了改造的烦恼。

ViewModel初始化优化#

一般来说,每个Card下面都会有一个ViewModel

StatusCourseCard.swift
struct StatusCourseCard: View {
@ObservedObject let vm: StatusCourseCardViewModel
...
StatusBusCard.swift
struct StatusBusCard: View {
@ObservedObject var vm: StatusBusCardViewModel
...

这些卡片的ViewModel,被外层StatusViewViewModel创建并持有。外层的ViewModel在初始化时就会去创建这些卡片的ViewModel

StatusContentViewModel.swift
@MainActor
class StatusContentViewModel: ObservableObject, @MainActor StatusCardController {
let weatherCardVM = StatusWeatherCardViewModel()
let libraryCardVM = StatusLibraryCardViewModel()
let courseCardVM = StatusCourseCardViewModel()
let busCardVM = StatusBusCardViewModel()
let sportCardVM = StatusSportCardVM()
let scheduleCardVM = StatusScheduleCardViewModel()
let casAlertCardVM = StatusCasAlertCardViewModel()
...
init() {
onInit()
}
...
func onInit() {
getAllVM().forEach { $0.onInit() }
}
...

这些卡片的ViewModel在创建时:

StatusBusCardViewModel:初始化数据并开始定位

StatusBusCardViewModel.swift
class StatusBusCardViewModel: ObservableObject, StatusCardViewModel, LocationListener {
...
func onInit() {
LocationManager.shared.registerListener(self)
update()
casContext.isLoginFlow.sink { [weak self] _ in
self?.update()
}
.store(in: &cancellables)
initTimer()
}
...

StatusCourseCardViewModel 从sqlite数据库拉数据

class StatusCourseCardViewModel: ObservableObject, StatusCardViewModel {
...
func onInit() {
NotificationCenter.default.addObserver(self, selector: #selector(onCourseListUpdated), name: .byKey(.ham_courseUpdated), object: nil)
updateCourseList()
}
...

这些操作都是需要耗时的,真的有那么需要在T2时刻就要做吗?能否挪到T3再开始做呢?

当然可以!但首先有个问题,T3时刻什么时候开始呢?好在,Apple提供了一个通知didBecomeActiveNotification。外层的ViewModel接收到该通知后,再调用每张卡片的ViewModel初始化即可。但是注意,这个didBecomeActiveNotification可能会触发多次,我们在代码层面上需要去重。

StatusView.swift
struct StatusView: View {
var body: some View {
...
.onReceive(NotificationCenter.default.publisher(
for: UIApplication.didBecomeActiveNotification
)) { _ in
vm.contentVM.doInit()
}
}
...

冷启动后第一帧时,因为没有数据,屏幕上不会展示任何卡片了,这显然不是我们想要的。接下来的缓存章节,就是为了解决这一点。

缓存#

最近在用小红书,发现小红书冷启动时一个很有意思的点:

APP冷启动时,会先展示上次的数据,再执行刷新步骤。

「执行刷新步骤」其实很好理解,这和我们的优化方案一致,在T3时刻再执行重逻辑。但是,「展示上次的数据」是怎么做到的?

或许可以…直接打开db获取数据?但这不才在日程卡优化补的坑嘛,冷启动阶段尽量不能操作重逻辑的IO。

答案如下:把要展示的首帧数据,保存在UserDefault里。

但是:

UserDefault 的原理也是读文件做IO啊,首次访问也会非常慢。

不过,这也总比打开数据库要强

以天气卡为例:

StatusWeatherCardViewModel.swift
struct StatusWeatherForecastInfo: SmartCodable {
var day: String = ""
var weather: String = ""
var dayTemp: String = ""
var nightTemp: String = ""
}
struct StatusWeatherDisplayInfo: SmartCodable {
var temp: String = ""
var weather: String = ""
var description: String = ""
var forecastInfo: [StatusWeatherForecastInfo] = []
}
@MainActor
class StatusWeatherCardViewModel: ObservableObject, LocationListener, StatusCardViewModel {
private let TAG = "StatusWeatherCardViewModel"
@Published var loadState = LoadStatus.unload
@Published var weatherInfo: StatusWeatherDisplayInfo?
@Published var errorMessage: String = ""
weak var controller: StatusCardController?
private var lastUpdateWeatherTimestamp: Int64 = 0
private var placemark: CLPlacemark? = nil
private var weatherObj: Weather? = nil
@Published var inited = false
// MARK: - Init
init() {
// 初始化时读取数据
let displayInfo = StatusWeatherDisplayInfo.deserialize(from: LocalStorageHelper.shared.getStringValue(.weatherCache))
_weatherInfo = .init(initialValue: displayInfo)
}
...
private func updateWeather(location: CLLocation) {
...
// 天气获取成功时
storeWeatherInfo(weatherInfo: weatherInfo)
}
private func storeWeatherInfo(weatherInfo: StatusWeatherDisplayInfo) {
// 存数据
LocalStorageHelper.shared.set(.weatherCache, value: weatherInfo.toJSONString())
}
...

这样,就能保证在冷启动第一帧,看到上次的天气数据。

其他优化#

背景图片#

状态卡的背景图片,是需要从网络上拉取的。冷启动时还没有拉取图片时,怎么办?

首先,这里的背景图片使用SDWebImage 组件,因为它支持图片硬盘缓存。也就意味着,冷启动时只要传入上次展示图片的链接,图片会从本地取出并加载。

StatusBackgroundView.swift
struct StatusBackgroundView: View {
@ObservedObject var vm: StatusBackgroundViewModel
let onGetImage: (UIImage) -> Void
var body: some View {
Group {
if let url = URL(string: vm.picURL) {
WebImage(url: url)
.resizable()
.onFailure { error in
Log.e("StatusBackgroundView", "load pic error", error)
vm.loadState = .loadedWithError
}
.onSuccess { image, data, cacheType in
Log.i("StatusBackgroundView", "load pic success")
vm.loadState = .loaded
vm.savePicCache()
onGetImage(image as UIImage)
}
.scaledToFill()
.transition(.fade(duration: 0.5))
}
}
.onReceive(NotificationCenter.default.publisher(
for: UIApplication.didBecomeActiveNotification
)) { _ in
vm.doInit()
}
}
}

在T3时刻更新图片链接数据。如果返回的图片链接列表里,存在当前展示的图片链接,就不需要更新缓存了。

StatusBackgroundViewModel.swift
class StatusBackgroundViewModel: ObservableObject {
...
func fetchPicUrl() {
if !inited {
return
}
loadState = .loading
HamRequestHelper.shared.doRequest(DailyPicRequest()) { [weak self] response in
if let error = response.error {
Log.e(TAG, "fetchPicUrl - error", error)
self?.loadState = .loadedWithError
return
}
guard let data = response.data as? DailyPicResponse else {
self?.loadState = .loadedWithError
return
}
if data.picUrlList.isEmpty {
Log.i(TAG, "fetchPicUrl - data is empty")
self?.loadState = .loadedWithError
return
}
Task { @MainActor in
guard let self else { return }
let firstLoad = self.firstLoad
self.firstLoad = false
if firstLoad {
if !self.picURL.isEmpty {
for picUrlData in data.picUrlList {
if picUrlData.url == self.picURL {
return
}
}
}
}
let url = data.picUrlList[ Int.random(in: data.picUrlList.indices) ].url
if self.picURL != url {
if firstLoad && !self.picURL.isEmpty {
try? await Task.sleep(for: .seconds(10))
}
withAnimation {
self.picURL = data.picUrlList[ Int.random(in: data.picUrlList.indices) ].url
}
}
Log.i(TAG, "fetchPicUrl - success => \(self.picURL)")
}
}
}
...
}

React Native模块初始化#

为什么会牵涉到React Native?虽然在二级页里才会用到React Native,但是APP初始化阶段更新bundle是有必要的。不过,这个更新并不紧急,没有必要因为这个占据冷启动时间。

首先,在ContentView上overlay一个RNView(老版本也是如此):

ContentView.swift
struct ContentView: View {
var body: some View {
...
.overlay(alignment: .topLeading) {
RNCommonView()
.frame(width: 0.5, height: 0.5)
}
...

接着,在T3时刻再加载RNView。

RNCommonView.swift
struct RNCommonView: View {
@State var show = false
var body: some View {
ZStack {
if show {
RNContainerAsyncView(moduleName: "RNCommon")
}
}
.onReceive(NotificationCenter.default.publisher(
for: UIApplication.didBecomeActiveNotification
)) { _ in
show = true
}
}
}

RNContainerAsyncView其实就是跑了个task去初始化RNView,为了不占渲染队列。而且因为React Native不支持在子线程初始化,还不能把task放在子线程里。

struct RNContainerView: UIViewRepresentable {
private let moduleName: String
init(moduleName: String) {
self.moduleName = moduleName
}
func makeUIView(context: Context) -> UIView {
RNViewManager.shared.getView(moduleName: moduleName)
}
func updateUIView(_ uiView: UIView, context: Context) {
}
}
struct RNContainerAsyncView: View {
let moduleName: String
@State var rnView: UIView? = nil
var body: some View {
ZStack {
if let rnView = rnView {
RNContainerInnerView(view: rnView)
}
}
.task {
if rnView != nil {
return
}
let view = RNViewManager.shared.getView(moduleName: moduleName)
await MainActor.run {
rnView = view
}
}
}
}

React Native的代码,可参考:

whu-ham
/
ham-rn
Waiting for api.github.com...
00K
0K
0K
Waiting...

优化后#

该阶段骤降至152ms。

System Interface Initialization优化#

看看该项的trace,发现有一堆的Map image:

说明APP里有一堆动态库:

那为什么不能把这些动态库打包进APP里,这样不就节省了dyld解析符号的时间吗?🤔

没错,cocoapods提供了一个选项,支持将包以静态库的方式引入:

Podfile
platform :ios, '15.1'
use_frameworks!
use_frameworks! :linkage => :static

但是,将Pods改成静态链接引入会导致包体积增大。不过,用少部分包体积增长换来更快的冷启动速度,是一个值得的trade-off。

完工后:

  • 动态库数量急剧减少
  • 该阶段启动市场优化至 350.04 ms,立省350ms

启动队列#

其实,之前就做过一版启动队列优化,有效果但是不大:

ColdStartManager.swift
class ColdStartManager {
static let shared = ColdStartManager()
var privacyAgree: Bool {
set {
LocalStorageHelper.shared.set(.app_isReadPrivacy, value: newValue)
}
get {
LocalStorageHelper.shared.getBool(.app_isReadPrivacy) ?? false
}
}
private init() {}
private weak var appDelegate: AppDelegate? = nil
private let prepareLaunchCodeStartTask = PrepareLaunchCodeStartTask()
private let primaryColdStartTask = PrimaryColdStartTask()
private let secondaryAsyncColdStartTask = SecondaryAsyncColdStartTask()
func onPrepareLaunch(delegate: AppDelegate) {
logTime("prepareLaunch") { prepareLaunchCodeStartTask.action(delegate: delegate, privacyAgreed: privacyAgree) }
}
func doColdStart(delegate: AppDelegate) {
appDelegate = delegate
logTime("primaryColdStartTask.action") { primaryColdStartTask.action(delegate: delegate, privacyAgreed: privacyAgree) }
logTime("secondaryAsyncColdStartTask.action") { secondaryAsyncColdStartTask.action(delegate: delegate, privacyAgreed: privacyAgree) }
}
func setPrivacyAgree() {
guard let appDelegate = appDelegate else {
return
}
LocalStorageHelper.shared.set(.app_isReadPrivacy, value: true)
logTime("primaryColdStartTask.onPrivacyAgree") { primaryColdStartTask.onPrivacyAgree(delegate: appDelegate) }
logTime("secondaryAsyncColdStartTask.onPrivacyAgree") { secondaryAsyncColdStartTask.onPrivacyAgree(delegate: appDelegate) }
NotificationCenter.default.post(name: .byKey(.ham_privacyRead), object: nil)
}
}
protocol ColdStartActionTask {
func action(delegate: AppDelegate, privacyAgreed: Bool)
func onPrivacyAgree(delegate: AppDelegate)
}
class PrepareLaunchCodeStartTask: ColdStartActionTask {
private let actionList: [ColdStartAction] = [...]
func action(delegate: AppDelegate, privacyAgreed: Bool) {
actionList.forEach { action in
action.action(delegate: delegate, privacyAgreed: privacyAgreed)
}
}
func onPrivacyAgree(delegate: AppDelegate) {
actionList.forEach { action in
action.onPrivacyAgree(delegate: delegate)
}
}
}
class PrimaryColdStartTask: ColdStartActionTask {
private let actionList: [ColdStartAction] = [...]
func action(delegate: AppDelegate, privacyAgreed: Bool) {
actionList.forEach { action in
action.action(delegate: delegate, privacyAgreed: privacyAgreed)
}
}
func onPrivacyAgree(delegate: AppDelegate) {
actionList.forEach { action in
action.onPrivacyAgree(delegate: delegate)
}
}
}
class SecondaryAsyncColdStartTask: ColdStartActionTask {
private let actionList: [ColdStartAction] = [...]
func action(delegate: AppDelegate, privacyAgreed: Bool) {
actionList.forEach { action in
Task {
action.action(delegate: delegate, privacyAgreed: privacyAgreed)
}
}
}
func onPrivacyAgree(delegate: AppDelegate) {
actionList.forEach { action in
Task {
action.onPrivacyAgree(delegate: delegate)
}
}
}
}

onPrepareLaunch 对应 willFinishLaunchingWithOptions,而doColdStart 对应didFinishLaunchingWithOptions

为什么效果不大?因为所有的task几乎都是在主线程上跑的。包括SecondaryAsyncColdStartTask,因为在主线程域开的Task,也是由主线程调度。

子任务Task改Detach#

将任务调度里的

Task {
...
}

改成

Task.detached(name: "ColdStartTask", priority: .background) {
...
}

这样可以不继承MainActor的上下文,减缓主线程压力。

但是,detach是有风险的,你必须要确保任务能detach才能这么做。

添加idle队列#

idle阶段在APP首帧展示时触发。

MainView.swift
struct MainView: View {
...
var body: some View {
...
.onReceive(NotificationCenter.default.publisher(
for: UIApplication.didBecomeActiveNotification
)) { _ in
ColdStartManager.shared.onIdle()
}
}
}

完工后的ColdStartManager#

ColdStartManager.swift
class ColdStartManager {
static let shared = ColdStartManager()
var privacyAgree: Bool {
set {
LocalStorageHelper.shared.set(.app_isReadPrivacy, value: newValue)
}
get {
LocalStorageHelper.shared.getBool(.app_isReadPrivacy) ?? false
}
}
private init() {}
private weak var appDelegate: AppDelegate? = nil
private let prepareLaunchCodeStartTask = PrepareLaunchCodeStartTask()
private let primaryMainColdStartTask = PrimaryMainColdStartTask()
private let primaryAsyncColdStartTask = PrimaryAsyncColdStartTask()
private let secondaryAsyncColdStartTask = SecondaryAsyncColdStartTask()
private let idleMainColdStartTask = IdleMainColdStartTask()
private let idleAsyncColdStartTask = IdleAsyncColdStartTask()
private var idleInited = false
func onPrepareLaunch(delegate: AppDelegate) {
logTime("prepareLaunch") { prepareLaunchCodeStartTask.action(delegate: delegate, privacyAgreed: privacyAgree) }
}
func onDidFinishLaunching(delegate: AppDelegate) {
appDelegate = delegate
logTime("primaryMainColdStartTask.action") {
primaryMainColdStartTask.action(
delegate: delegate,
privacyAgreed: privacyAgree
)
}
Task
.detached(name: "ColdStartTask", priority: .background) { [
primaryAsyncColdStartTask,
secondaryAsyncColdStartTask,
privacyAgree
] in
logTime("primaryColdStartTask.action") {
primaryAsyncColdStartTask.action(
delegate: delegate,
privacyAgreed: privacyAgree
)
}
logTime("secondaryAsyncColdStartTask.action") {
secondaryAsyncColdStartTask.action(
delegate: delegate,
privacyAgreed: privacyAgree
)
}
}
}
func onPrivacyAgreed() {
guard let appDelegate = appDelegate else {
return
}
LocalStorageHelper.shared.set(.app_isReadPrivacy, value: true)
NotificationCenter.default.post(name: .byKey(.ham_privacyRead), object: nil)
logTime("primaryMainColdStartTask.onPrivacyAgree") {
primaryMainColdStartTask.onPrivacyAgree(delegate: appDelegate)
}
Task.detached(name: "ColdStartTask - After privacy read", priority: .background) { [primaryAsyncColdStartTask, secondaryAsyncColdStartTask, appDelegate] in
logTime("primaryColdStartTask.onPrivacyAgree") {
primaryAsyncColdStartTask.onPrivacyAgree(delegate: appDelegate)
}
logTime("secondaryAsyncColdStartTask.onPrivacyAgree") {
secondaryAsyncColdStartTask.onPrivacyAgree(delegate: appDelegate)
}
}
}
func onIdle() {
guard let appDelegate = appDelegate, !idleInited else {
return
}
idleInited = true
Task { @MainActor in
logTime("idleMainColdStartTask.action") {
idleMainColdStartTask.action(
delegate: appDelegate,
privacyAgreed: privacyAgree
)
}
}
Task.detached(name: "ColdStartTask - onIdle", priority: .background) { [idleAsyncColdStartTask, appDelegate, privacyAgree] in
logTime("idleAsyncColdStartTask.action") {
idleAsyncColdStartTask.action(delegate: appDelegate, privacyAgreed: privacyAgree)
}
}
}
}

目前的启动队列:

  • 必须启动前完成(主线程/同步依赖)
  • 可异步但需尽快(后台异步)
  • idle 触发(完全可延迟)

然后,我们把各冷启动Task按照需要放进不同的队列里。

举个例子,像Firebase这种监测崩溃的SDK,需要在启动时就初始化,因此放在PrimaryMainColdStartTask里;

而像QQ SDK这种不急于初始化的操作,就可以放在IdleAsyncColdStartTask 里。

成果与反思#

优化前:

优化后:

优化后,APP的启动时间骤降至平均500ms,最快仅需300ms。

当然,这个过程也充满了各种坑。比如我把Firebase的SDK初始化过程放到idle阶段,会导致APP启动即崩溃的上报失效。还有,我把Xlog的初始化放在子线程,导致部分日志丢失。本质上,启动任务队列本身就是一个不断权衡(trade-off)的过程。

好在,经过大量反复测试与调整,我最终拿到了一个稳定可运行的启动队列。

提升APP冷启动速度-iOS篇
https://blog.nowcent.cn/posts/dev/提升app冷启动速度-ios篇/
作者
orangeboy
发布于
2026-02-10
许可协议
CC BY-NC-SA 4.0