iOS创建App小组件(Widget)
iOS创建App小组件(Widget)

iOS创建App小组件(Widget)

创建 Widget 扩展

添加 Widget Extension

1、在 Xcode 中打开项目。

2、点击 File → New → Target。

3、选择 Widget Extension,点击 Next。

4、取一个合适的名称,例如 BankletWidget。

5、添加后,Xcode 会询问是否激活扩展,选择“Activate”。

Widget 文件

Xcode 会自动生成一些文件来组织和管理Widget功能,以下是 BankletWidget 目录下各个文件的作用:

1、BankletWidgetBundle.swift(Widget 入口)

作用:

WidgetBundle 是 多个 Widget 的集合,用于在 一个 App Extension 里注册多个 Widget(比如 普通小组件 + Live Activity)。

import WidgetKit
import SwiftUI

@main
struct BankletWidgetBundle: WidgetBundle {
    var body: some Widget {
        BankletWidget() // 主要的 Widget
        BankletWidgetLiveActivity() // 可能还有 Live Activity
    }
}

@main:表示这个是 Widget 入口。

WidgetBundle:可以 包含多个 Widget,比如:

BankletWidget(普通 Widget)

BankletWidgetLiveActivity(Live Activity)

如果 App 只需要一个 Widget,可以直接在这里返回 BankletWidget()。

2、BankletWidget.swift(普通 Widget 逻辑)

这是主要的 Widget 文件,定义了 Widget 的内容、数据来源和展示方式。

import WidgetKit
import SwiftUI

struct Provider: AppIntentTimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), configuration: ConfigurationAppIntent())
    }
    // ...
}

struct SimpleEntry: TimelineEntry {
    let date: Date
    let configuration: ConfigurationAppIntent
}

struct BankletWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        // ...
    }
}

struct BankletWidget: Widget {
    let kind: String = "BankletWidget"

    var body: some WidgetConfiguration {
        // ...
    }
}

extension ConfigurationAppIntent {
    fileprivate static var smiley: ConfigurationAppIntent {
        // ...
    }
}

#Preview(as: .systemSmall) {
    BankletWidget()
} timeline: {
    SimpleEntry(date: .now, configuration: .smiley)
    SimpleEntry(date: .now, configuration: .starEyes)
}

AppIntentConfiguration 允许 Widget 可配置(如选择存钱罐类型)。

Provider() 负责提供 Widget 的数据(getTimeline)。

BankletWidgetEntryView 是 Widget 的 UI 视图。

3、BankletWidgetLiveActivity.swift(Live Activity 组件,类似锁屏动态显示)

Live Activity 允许在锁屏或动态岛中显示实时数据(如存钱进度、定时任务)。

import ActivityKit
import WidgetKit
import SwiftUI

struct BankletWidgetAttributes: ActivityAttributes {
    public struct ContentState: Codable, Hashable {
        // Dynamic stateful properties about your activity go here!
        var emoji: String
    }

    // Fixed non-changing properties about your activity go here!
    var name: String
}

struct BankletWidgetLiveActivity: Widget {
    var body: some WidgetConfiguration {
        // ...
    }
}

extension BankletWidgetAttributes {
    fileprivate static var preview: BankletWidgetAttributes {
        BankletWidgetAttributes(name: "World")
    }
}

extension BankletWidgetAttributes.ContentState {
    fileprivate static var smiley: BankletWidgetAttributes.ContentState {
       // ...
    }
}

#Preview("Notification", as: .content, using: BankletWidgetAttributes.preview) {
   BankletWidgetLiveActivity()
} contentStates: {
    BankletWidgetAttributes.ContentState.smiley
    BankletWidgetAttributes.ContentState.starEyes
}

ActivityConfiguration 让 Widget 变成 Live Activity(实时更新)。

适用于实时追踪目标金额、每日存钱进度等场景。

如果不需要 Live Activity,可以删除这个文件。

4、AppIntent.swift(Widget的可配置项)

作用:

这个文件定义 Widget 的配置选项,用户可以在添加 Widget 时自定义参数。

import WidgetKit
import AppIntents

struct ConfigurationAppIntent: WidgetConfigurationIntent {
    static var title: LocalizedStringResource = "Configuration"
    static var description = IntentDescription("This is an example widget.")

    // An example configurable parameter.
    @Parameter(title: "Favorite Emoji", default: "😃")
    var favoriteEmoji: String
}

5、Assets.xcassets(Widget 资源)

存放 Widget 专属图片、颜色等资源,不会影响主 App。

默认包含 AppIcon 资源(用于 Widget 图标)。

可以在这里放置 不同大小的 Widget 背景图。

6、Info.plist(Widget 配置文件)

Info.plist 定义 Widget 的元数据,包括:

NSExtension:声明 Widget 是 WidgetKit 扩展。

NSExtensionAttributes:设置 支持的 Widget 尺寸。

如果 Widget 出现找不到或无法使用的问题,可能是 Info.plist 缺少必要的配置。

运行Widget

首次创建Widget并运行,如果选择WidgetExtension,可能会报如下错误:

因此,在测试Widget时,需要选择项目(如“piglet”)运行,而不是选择WidgetExtension。

运行后,可以在iOS小组件中,查看示例Widget。

需要注意的是,如果运行示例时,发生某些意外的报错,可以尝试以下方法:

1、在访达中找到DerivedData文件夹,删除派生数据。

~/Library/Developer/Xcode/DerivedData

2、关闭并重新打开Xcode。

以上方法用于解决运行Xcode项目时,Widget可能导致的报错问题。

解析Widget文件

BankletWidget.swift文件

BankletWidget.swift文件定义了iOS 17+小组件 (Widget),使用 WidgetKit 和 AppIntents 来提供可配置的时间表更新。

1、Provider(数据提供者)
struct Provider: AppIntentTimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), configuration: ConfigurationAppIntent())
    }

    func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
        SimpleEntry(date: Date(), configuration: configuration)
    }

    func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
        var entries: [SimpleEntry] = []

        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(date: entryDate, configuration: configuration)
            entries.append(entry)
        }

        return Timeline(entries: entries, policy: .atEnd)
    }
}

这个结构体 Provider 负责提供 Widget 需要显示的数据,它实现了 AppIntentTimelineProvider 协议,定义了 Widget 如何更新数据。

1、placeholder(in context:):用于Widget Gallery 预览,显示静态占位数据(如默认内容)。

当 Widget 还未获取真实数据 时(比如刚添加到主屏幕,或者系统重启后第一次加载)。

在 Widget Gallery(小组件选择界面)里显示预览时。

只用于占位,保证 Widget 在加载正式数据前不会是空白的。

应该是静态的,不要依赖外部动态数据(如网络请求、数据库)。

2、snapshot(for:in:):当 Widget 需要快速更新一次数据时会调用,通常用于锁屏小组件的快照。

snapshot 应该返回一个尽量真实的 Widget 预览,比 placeholder 更贴近实际展示的数据。

在某些情况下,可以提供 模拟数据,让 Widget 预览效果更直观。

3、timeline(for:in:)

这里创建了未来 5 个小时的时间表,每个小时创建一个 SimpleEntry 作为 Widget 的显示内容。

Timeline(entries: entries, policy: .atEnd) 表示 用完所有的 entries 后再请求新的数据(policy: .atEnd)。

placeholder、snapshot 和 timeline 这三个方法都是 AppIntentTimelineProvider 协议要求实现的。它们分别用于占位、预览和正式时间线展示,目的是让 Widget 在不同情况下都有合适的数据可以显示。

2、SimpleEntry(数据模型)
struct SimpleEntry: TimelineEntry {
    let date: Date
    let configuration: ConfigurationAppIntent
}

SimpleEntry 是时间轴上的数据条目,它遵循 TimelineEntry 协议。

日期 (date) 配置 (configuration),代表 Widget 需要展示的数据,它的作用是存储 Widget 在某个时间点要显示的数据。

SimpleEntry 主要用于 placeholder、snapshot 和 timeline 方法中。

3、BankletWidgetEntryView(小组件 UI)
struct BankletWidgetEntryView: View {
    var entry: Provider.Entry

    var body: some View {
        VStack {
            Text("Time:")
            Text(entry.date, style: .time)

            Text("Favorite Emoji:")
            Text(entry.configuration.favoriteEmoji)
        }
    }
}

BankletWidgetEntryView 负责小组件的 UI 展示

它使用 SwiftUI 显示:

当前时间(entry.date)。

用户配置的 Emoji(entry.configuration.favoriteEmoji)。

在 BankletWidgetEntryView 中:

var entry: Provider.Entry

entry 由 Provider 提供的数据填充。

Provider.Entry 实际上是 SimpleEntry,它存储着时间(date)用户配置的 emoji(configuration)

4、BankletWidget(小组件本体)
struct BankletWidget: Widget {
    let kind: String = "BankletWidget"

    var body: some WidgetConfiguration {
        AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
            BankletWidgetEntryView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget)
        }
    }
}

BankletWidget 是小组件的主体,它定义了小组件的结构,符合 Widget 协议,这意味着它是一个可以被 iOS 系统识别并显示的小组件。

kind: String = “BankletWidget” 是 Widget 的唯一标识符,用于在系统中识别不同的小组件。

body: some WidgetConfiguration 定义 Widget 的更新方式,是 Widget 协议的要求,它返回一个 WidgetConfiguration,定义了 Widget 的行为、外观和数据来源。

AppIntentConfiguration 是配置 Widget 的主要方式。它包含了:

kind: kind:指定 Widget 的类型(与前面定义的 kind 匹配)。

intent: ConfigurationAppIntent.self:指定使用的配置类型,这里是 ConfigurationAppIntent,它包含了用户自定义的配置(例如:Emoji)。

provider: Provider():指定一个数据提供者,Provider 是实现 TimelineProvider 协议的对象,它提供数据(例如时间和 Emoji)。

BankletWidgetEntryView(entry: entry):用于展示 Widget 的视图内容,接收 Provider 提供的数据 (entry) 并进行渲染(例如:显示时间和 Emoji)。

.containerBackground(.fill.tertiary, for: .widget):设置 Widget 的背景样式,这里使用 .fill.tertiary,并将其应用于整个 Widget 背景。

5、ConfigurationAppIntent(可配置项)
extension ConfigurationAppIntent {
    fileprivate static var smiley: ConfigurationAppIntent {
        let intent = ConfigurationAppIntent()
        intent.favoriteEmoji = "😀"
        return intent
    }

    fileprivate static var starEyes: ConfigurationAppIntent {
        let intent = ConfigurationAppIntent()
        intent.favoriteEmoji = "🤩"
        return intent
    }
}

ConfigurationAppIntent 允许用户配置 Widget,比如选择自己喜欢的 Emoji。

这里 smiley 和 starEyes 是 两个默认的配置示例,用于 Widget 预览

6、Widget 预览
#Preview(as: .systemSmall) {
    BankletWidget()
} timeline: {
    SimpleEntry(date: .now, configuration: .smiley)
    SimpleEntry(date: .now, configuration: .starEyes)
}

#Preview 是一个 SwiftUI 特性,允许在 Xcode 中快速预览组件的外观。这里,as: .systemSmall 指定了预览的 大小,即显示为小型(systemSmall)Widget。

BankletWidget() 是实际需要预览的 Widget 视图。这里我们预览的是 BankletWidget,它展示了 Widget 在特定大小下的样式和内容。

timeline 是用于展示不同时间条目的配置。在这个案例中,我们使用了两个 SimpleEntry,它们代表 Widget 在不同配置下的内容:

1、SimpleEntry(date: .now, configuration: .smiley):这个条目使用 smiley 配置,意味着 favoriteEmoji 设置为 “😀”。

2、SimpleEntry(date: .now, configuration: .starEyes):这个条目使用 starEyes 配置,意味着 favoriteEmoji 设置为 “🤩”。

timeline 指定了 Widget 在不同时间点的显示内容。在这里,SimpleEntry 就是代表了在某个时间点的 Widget 配置,它通过 configuration 来指定 favoriteEmoji。

实际应用Widget

例如,在iOS应用中想要实现下面的Widget效果:

主要涉及到LottieFiles动画、存钱罐的信息共两部分内容。经过查询了解到Widget并不适用于显示LottieFiles动画,同时经过实测发现Widget无法显示Gif动画,只能显示静态图片。

同时,Widget也不支持SwiftData,因此在展示存钱罐信息时,需要从iOS应用端将数据传递到Widget中,比较常用的传递方式为:通过App Group共享数据,将主应用传递到Widget。

其中App Group配置步骤,请见《SwiftUI UserDefaults使用App Group共享数据》,这里不做过多的累赘。

iOS应用端配置

在iOS应用端,配置一个保存存钱罐数据的方法,在适当的时间调用该方法,将存钱罐的数据共享到UserDefaults中:

func saveWidgetData() {
    let userDefaults = UserDefaults(suiteName: "group.com.fangjunyu.piglet")
    // 存储存钱罐数据
    userDefaults?.set(piggyBank[0].icon, forKey: "piggyBankIcon")
    userDefaults?.set(piggyBank[0].name, forKey: "piggyBankName")
    userDefaults?.set(piggyBank[0].amount, forKey: "piggyBankAmount")
    userDefaults?.set(piggyBank[0].targetAmount, forKey: "piggyBankTargetAmount")
    userDefaults?.set(LoopAnimation, forKey: "LoopAnimation")

    // 然后手动触发 Widget 刷新
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
        WidgetCenter.shared.reloadTimelines(ofKind: "BankletWidget")
        WidgetCenter.shared.reloadTimelines(ofKind: "BankletWidgetBackground")
    }
}

我在这里考量的是退出应用执行该方法:

VStack {}
.onChange(of: scenePhase) { newPhase in
    if newPhase == .background {
        // 在应用进入后台时保存数据
        saveWidgetData()
        print("应用移入后台,调用Widget保存数据")
    }
    if newPhase == .inactive {
        // 应用即将终止时保存数据(iOS 15+)
        saveWidgetData()
        print("非活跃状态,调用Widget保存数据")
    }
}

通过scenePhase检测应用的生命周期,在暂停和后台运行时,调用saveWidgetData()保存数据。

在saveWidgetData方法中,除了保存UserDefaults,我还调用了WidgetCenter,这里的ofKind和Widget中的kind相对应。

struct BankletWidget: Widget {
    let kind: String = "BankletWidget"  // kid 相对应
    
    var body: some WidgetConfiguration {
        ...
    }
}

在 iOS 中,当通过 UserDefaults 更新共享的数据时,Widget 可能不会立即更新。原因可能包括:

1、Widget Timeline 更新延迟

TimelineProvider 会根据设定的时间线更新 Widget。即使UserDefaults 更新了数据,Widget 并不会立刻反应。需要确保 Widget 的时间线被刷新。可以手动调用 WidgetCenter.shared.reloadTimelines(ofKind:) 来强制刷新 Widget 的时间线。

WidgetCenter.shared.reloadTimelines(ofKind: "BankletWidget")

这行代码会强制更新指定的 Widget(通过 kind)。如果更新 UserDefaults 后调用了这个方法,Widget 会及时反映数据的变化。

2、Widget Timeline Policy

如果在 getTimeline(in:completion:) 中使用的是 .atEnd 的更新策略,那么 Widget 只有在下一次更新时才会加载新的数据。这种更新策略通常适用于一次性展示的数据,而不适用于实时更新的需求。可以调整策略,例如使用 .after 来确保 Widget 会在某个时间点后更新。

3、Widget 不能实时读取 UserDefaults 的变化

Widget 会读取共享的 UserDefaults 数据,但是它不是实时监听数据变化的。要确保 Widget 在 UserDefaults 数据变化时更新,需要手动触发 Widget 的刷新,如前面提到的 WidgetCenter.shared.reloadTimelines(ofKind:)。

4、App Group 和 UserDefaults 的同步问题

在 Widget 和主应用之间共享 UserDefaults 时,可能会存在同步延迟。如果更新了主应用中的 UserDefaults,Widget 可能需要一些时间才能读取到新值。在这种情况下,可以使用手动刷新时间线来确保更新立即生效。

Widget配置

在Widget中,通过相同的UserDefaults(suiteName:)读取主应用存储的存钱罐数据。

struct BankletWidgetEntryView : View {
    @State private var piggyBankIcon: String = ""
    @State private var piggyBankName: String = ""
    @State private var piggyBankAmount: Double = 0.0
    @State private var piggyBankTargetAmount: Double = 0.0
    @State private var LoopAnimation: String = ""
    
    var entry: Provider.Entry
    
    var body: some View {
        VStack {
            // 读取主应用存储的存钱罐数据
            Text("Piggy Bank Name: \(piggyBankName)")
            Text("Amount: \(piggyBankAmount, specifier: "%.2f")")
            Text("Target Amount: \(piggyBankTargetAmount, specifier: "%.2f")")
            Image(systemName: "piggyBankIcon") // 显示存钱罐图标
            Image(LoopAnimation) // 显示存钱罐图标
        }
        .onAppear {
            // 读取存钱罐的数据
            let userDefaults = UserDefaults(suiteName: "group.com.fangjunyu.piglet")
            piggyBankIcon = userDefaults?.string(forKey: "piggyBankIcon") ?? ""
            piggyBankName = userDefaults?.string(forKey: "piggyBankName") ?? ""
            piggyBankAmount = userDefaults?.double(forKey: "piggyBankAmount") ?? 0.0
            piggyBankTargetAmount = userDefaults?.double(forKey: "piggyBankTargetAmount") ?? 0.0
            LoopAnimation = userDefaults?.string(forKey: "LoopAnimation") ?? ""
        }
    }
}

在使用自定义的Color(hex:)方法时,发现Color(hex:)方法必须单独定义在BankletWidget文件中才能生效,定义在Widget项目中,并不会生效,并且还会报错:

Invalid redeclaration of 'init(hex:)'

还有一个问题,那就是如果想要设置上下分层的背景,在Widget中会存在背景含有边距的情况。

这个效果在Xcode预览以及真机上,都是一致的。我的理解是白色区域实际上就是Widget的安全区域,不允许Widget内容占据这一安全区域。

如果想要实现Widget的背景颜色,可以配置BankletWidget文件的containerBackground部分:

struct BankletWidget: Widget {
    let kind: String = "BankletWidget"
    
    var body: some WidgetConfiguration {
        AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
            BankletWidgetEntryView(entry: entry)
                .containerBackground(.fill.secondary, for: .widget) // 背景色
        }
    }
}

当设置containerBackground为紫色时:

BankletWidgetEntryView(entry: entry)
    .containerBackground(.purple, for: .widget) // 背景色

背景颜色变为紫色。

根据containerBackground的定义,表示有两种用法。

一种是设置背景颜色:

someView
    .containerBackground(Color.blue, for: .navigation) // 设置导航容器背景为蓝色

第二种则可以设置一个自定义视图(例如一张图片)作为容器背景:

someView
    .containerBackground(for: .navigation) {
        Image("backgroundImage") // 自定义的背景视图
    }

通过设置一个橙色和白色上下分层的图片:

在containerBackground中应用:

BankletWidgetEntryView(entry: entry)
    .containerBackground(for: .widget) {
        Image("WidgetBackground")
    }

完成小组件的整个设计效果。

背景图片需要放在Widget项目的Assets中,否则可能不会显示。

总结

因为本篇文章篇幅过长,因此仅解析Widget文件部分,WidgetLiveActivity则考虑在以后的时间里,推出相关的解析文章。

通过使用Widget,可以进一步扩展应用的可用性和功能。

关于Widget的扩展知识,比如根据小组件的大小显示不同的内容,则在后续的文章中进一步分析,本篇的知识分享先讲到这里。

相关文章

1、SwiftUI UserDefaults使用App Group共享数据:https://fangjunyu.com/2025/01/24/swiftui%e9%9a%94%e7%a6%bbuserdefaults%e5%ae%9e%e4%be%8b/

2、小组件、复杂功能和实时活动:https://developer.apple.com/cn/widgets/

3、WidgetKit:https://developer.apple.com/documentation/widgetkit

4、ActivityKit:https://developer.apple.com/documentation/ActivityKit/

扩展知识

Timeline是什么?

Timeline 是 WidgetKit 中用来定义和管理 Widget 更新周期的数据结构。它允许Widget 在不同时间点显示的内容,通常由时间轴条目(TimelineEntry) 组成。每个条目包含了一个指定时间和展示内容。

1、TimelineEntry:表示在特定时间点应该展示的数据。它通常包含一个日期 和 数据,可以把它理解为在某一时刻 Widget 应该展示的状态。

2、Timeline:由多个 TimelineEntry 组成,定义了 在一段时间内 Widget 的更新计划。

Timeline 的作用

Timeline 控制了 Widget 何时更新,每个 TimelineEntry 中的内容会被按照时间顺序展示。可以为每个时间点设定数据,这样 Widget 就会按照预定的时间周期更新。

Timeline 的基本结构

Timeline 包含一个 条目数组,这个数组包含了 Widget 在不同时间点显示的内容。

代码示例

func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
    var entries: [SimpleEntry] = []
    
    let currentDate = Date()
    
    // 创建未来5个小时的条目
    for hourOffset in 0..<5 {
        let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
        let entry = SimpleEntry(date: entryDate, configuration: configuration)
        entries.append(entry)
    }
    
    // 返回时间轴和更新策略(.atEnd 表示当最后一个条目显示完后才会更新)
    return Timeline(entries: entries, policy: .atEnd)
}

1、创建条目:这里,我们生成了一个未来 5 小时的时间轴,每一小时生成一个 SimpleEntry。

SimpleEntry 包含两个字段:date 和 configuration。date 是时间,configuration 是展示的数据(如 Emoji)。

2、返回 Timeline:在 Timeline 的构造函数中传入 entries(条目数组)和 policy(更新策略)。

Timeline 的更新策略(policy)

.atEnd:表示所有条目都展示完后,再请求新的数据更新。适用于需要定时更新的 Widget。

.after(Date):指定在某个特定日期后更新 Widget。

.never:表示永不自动更新,通常在静态 Widget 或不需要更新的场景中使用。

Timeline 的生命周期

1、时间条目(TimelineEntry)定义了 Widget 在特定时间点的显示内容。

2、Timeline 会随着时间的推移按照设定的规则逐步更新 Widget 的内容。

3、每次 Widget 需要刷新数据时,Timeline 会重新生成,通常是因为时间周期已到。

何时使用 Timeline?

动态数据更新:例如,展示实时进度条、天气信息、新闻头 等动态内容时,使用 Timeline 可以让Widget 按照预设的时间间隔自动更新。

时间驱动更新:比如,展示定时提醒、任务进度、每日摘要,可以为这些内容创建定期更新的时间轴。

Timeline 使用示例

假设开发的 Widget 显示一个 天气预报,希望每小时更新一次天气数据:

func timeline(for configuration: WeatherConfiguration, in context: Context) async -> Timeline<WeatherEntry> {
    var entries: [WeatherEntry] = []
    
    let currentDate = Date()
    
    // 每小时更新天气数据
    for hourOffset in 0..<24 {
        let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
        
        // 假设 fetchWeather() 是获取天气数据的异步方法
        let weatherData = await fetchWeather(for: entryDate)
        
        // 创建并添加条目
        let entry = WeatherEntry(date: entryDate, weather: weatherData)
        entries.append(entry)
    }
    
    // 返回时间轴,设置为 .atEnd,表示所有条目都展示完后会再请求新的数据
    return Timeline(entries: entries, policy: .atEnd)
}

在这个示例中,24小时的天气预报将根据 Timeline 中的条目展示,并且每小时更新一次数据。

如果您认为这篇文章给您带来了帮助,您可以在此通过支付宝或者微信打赏网站开放者。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注