普通视图

发现新文章,点击刷新页面。
昨天 — 2025年9月14日掘金 iOS

iOS 26 仅需几行代码让 SwiftUI 7 液态玻璃界面焕发新春

2025年9月14日 14:31

在这里插入图片描述

概述

在今年的 WWDC 25 中,苹果为全平台推出了崭新的液态玻璃(Liquid Glass)皮肤。不仅如此,Apple 在此基础之上还打造了一整套超凡脱俗的动画和布局体系让 SwiftUI 7 界面焕发新机。

在这里插入图片描述

现在,我们只需寥寥几行代码就能将原本平淡无奇、乏善可陈的 SwiftUI 布局变成上面这般鲜活灵动。

在这里插入图片描述

想知道如何实现吗?看这篇就对啦!

在本篇博文中,您将学到如下内容:

  1. “一条大河窄又长”
  2. SwiftUI 7 全新玻璃特效来袭
  3. 融入,鲜活!

那还等什么呢?让我们马上迈入液态玻璃奇妙的特效世界吧? Let‘s go!!!;)


1. “一条大河窄又长”

在如今 App 现代化布局中,秃头小码农们非常喜爱像下面这般简洁、小巧、紧凑的全局菜单系统:

在这里插入图片描述

它就像一条长长的河流,伸缩自如,温柔又调皮的流入用户的心坎里。

不幸的是,目前它仿佛少了一点灵动的气息,而且感觉和 WWDC 25 中全新的液态玻璃也不太般配。

struct BadgesView: View {
    @Environment(ModelData.self) private var modelData
    @State private var isExpanded: Bool = false
    
    var body: some View {
        VStack(alignment: .center, spacing: Constants.badgeButtonTopSpacing) {
                if isExpanded {
                    VStack(spacing: Constants.badgeSpacing) {
                        ForEach(modelData.earnedBadges) {
                            BadgeLabel(badge: $0)
                        }
                    }
                }

                Button {
                    withAnimation {
                        isExpanded.toggle()
                    }
                } label: {
                    ToggleBadgesLabel(isExpanded: isExpanded)
                        .frame(width: Constants.badgeShowHideButtonWidth,
                               height: Constants.badgeShowHideButtonHeight)
                     
                }
                #if os(macOS)
                .tint(.clear)
                #endif
            }
            .frame(width: Constants.badgeFrameWidth)
    }
}

诚然,我们可以利用 SwiftUI 优秀的动画底蕴重新包装上面 BadgesView 视图的动画和转场效果,但这需要秃头小码农们宝贵的时间和头发,而且效果往往强差人意。

在这里插入图片描述

不过别担心,从 SwiftUI 7(iOS 26 / iPadOS 26 / macOS 26)开始,我们有了全新的选择,简单的不要不要的!

2. SwiftUI 7 全新玻璃特效来袭

从 WWDC 25 开始,全面支持 Liquid Glass 的 SwiftUI 7 推出了玻璃特效容器 GlassEffectContainer ,让我们真的可以对玻璃“为所欲为”:

在这里插入图片描述

GlassEffectContainer 能把多个带 glassEffect(_:in:) 的视图合并成一张“可变形的联合玻璃”,既省性能又能让玻璃形状彼此融合、 变形(morph)。

核心要点:

  • 用法:给子视图添加 .glassEffect(.liquid, in: container) 修改器,系统会把它们自动收集到同一个 GlassEffectContainer 中;
  • 效果:子视图的玻璃形状不再各自独立,而是当成一个整体渲染,可互相吸引、拼接、渐变和 morph;
  • 控制融合:通过容器的 spacing 值调节——值越大,子视图相距越远时就开始“粘”在一起;
  • 并发:@MainActor 隔离,线程安全。

总而言之,GlassEffectContainer 让多块“液态玻璃”合成一块可 morph 的超级玻璃,性能更高、动画更连贯。

在这里插入图片描述

同时,SwiftUI 7 还新增了两个配套方法 glassEffect(_:in:)glassEffectID(_:in:) : 在这里插入图片描述

在这里插入图片描述

我们可以利用它们结合 Namespace 来完成液态玻璃世界中的视图动画效果。

另外 SwiftUI 7 还专门为 Button 视图添加了 glass 按钮样式,真可谓超级“银杏化”:

在这里插入图片描述

有了这些 SwiftUI 中的宝贝,小伙伴们可以开始来打造我们的梦幻玻璃天堂啦!

3. 融入,鲜活!

将之前的 BadgesView 视图重装升级为如下实现:

struct BadgesView: View {
    @Environment(ModelData.self) private var modelData
    @State private var isExpanded: Bool = false
    @Namespace private var namespace
    
    var body: some View {
        GlassEffectContainer(spacing: Constants.badgeGlassSpacing) {
            VStack(alignment: .center, spacing: Constants.badgeButtonTopSpacing) {
                if isExpanded {
                    VStack(spacing: Constants.badgeSpacing) {
                        ForEach(modelData.earnedBadges) {
                            BadgeLabel(badge: $0)
                                .glassEffect(.regular, in: .rect(cornerRadius: Constants.badgeCornerRadius))
                                .glassEffectID($0.id, in: namespace)
                        }
                    }
                }

                Button {
                    withAnimation {
                        isExpanded.toggle()
                    }
                } label: {
                    
                    ToggleBadgesLabel(isExpanded: isExpanded)
                        .frame(width: Constants.badgeShowHideButtonWidth,
                               height: Constants.badgeShowHideButtonHeight)
                     
                }
                .buttonStyle(.glass)
                #if os(macOS)
                .tint(.clear)
                #endif
                .glassEffectID("togglebutton", in: namespace)
            }
            .frame(width: Constants.badgeFrameWidth)
        }
    }
}

上面这段新代码把“ earned 徽章列表”与底部的“展开/收起”按钮一起放进同一个 GlassEffectContainer 容器中,从而让它们全部参与 iOS 26 的「液态玻璃」合并渲染。

下面按“玻璃特性”逐句拆解:

  1. GlassEffectContainer(spacing: …)
  • 建立一块「联合玻璃画布」。
  • spacing 决定徽章彼此、徽章与按钮之间多早开始“粘”成一体:值越大,离得越远就开始融合。
  1. 展开时才出现的 VStack + ForEach
  • 每个 BadgeLabel 同时挂两个修饰符:
    • .glassEffect(.regular, in: .rect(cornerRadius: …))
      声明“我是 regular 风格玻璃,形状是圆角矩形”。
    • .glassEffectID(badge.id, in: namespace)
      给玻璃发身份证;同一 namespace 里身份证不同,SwiftUI 就能在增减徽章时做“液态 morph”——旧玻璃流走、新玻璃流进来,而不是生硬闪现。
  1. 底部 Button
  • .buttonStyle(.glass) 让按钮本身也是玻璃,但风格、圆角与徽章不同。
  • 同样用 .glassEffectID("togglebutton", in: namespace) 注册身份证,于是按钮的玻璃和上面徽章的玻璃被当成“同一张可变形大图”处理。
  • 展开/收起时,按钮玻璃会与刚出现(或消失)的徽章玻璃在边缘处“拉丝”融合,形成液态过渡。
  1. withAnimation { isExpanded.toggle() }
  • 状态变化被包进动画块,GlassEffectContainer 会同步驱动所有玻璃路径的 morph 动画:
    • 徽章从 0 高度“流”出来,边缘先与按钮玻璃粘连,再各自分离成独立圆角矩形。
    • 收起时反向流回,最终只剩按钮玻璃。
  1. 整体效果
    用户看到的不是“一行行控件出现”,而是一块完整的「可变玻璃」:
    • 展开 → 玻璃区域向下延伸,新徽章像水泡一样从主体里分裂长出;
    • 收起 → 多余部分被“吸”回按钮,边缘圆润地收缩消失。
      全程保持同一高光、折射、模糊背景,性能也优于多图层叠加。

在这里插入图片描述

简单来说,上面的实现用 GlassEffectContainer 把徽章与按钮收进同一块「液态玻璃」,凭借 glassEffectIDnamespace 让它们在展开/收起时像流体一样自然融合、morph,呈现出 iOS 26 独有的“整块玻璃可生长可收缩”的视觉魔法。

在这里插入图片描述

要注意哦,上面动图中按钮组背后的阴影是由于 gif 图片显示局限导致的,模拟器和真机实际测试的阴影效果可是美美哒的呢!

我们把 BadgesView 视图嵌入到主视图中,宝子们再来一起欣赏一下叹为观止的液态玻璃动画效果吧: 在这里插入图片描述

大功告成,打完收工,棒棒哒!💯

在这里插入图片描述

总结

在本篇文章中,我们讨论了在 iOS 26/iPadOS 26 里如何使用 SwiftUI 7 最新的液体玻璃系统来装饰小伙伴们的 App 界面。

在这里插入图片描述

感谢观赏,再会吧!8-)

Redux 中›ABC三个页面是如何通信的?

作者 littleplayer
2025年9月14日 12:31

你的这种感觉非常正常!这也是很多初学者对 Redux 最大的误解。如果 A、B、C 三个页面分别有自己的 Store,那你就完全违背了 Redux 最核心的“单一数据源”原则,自然会陷入无法通讯的困境。

Redux 的核心理念是:整个应用有且只有一个全局 Store。A、B、C 三个页面共享这个唯一的 Store,而不是各自拥有一个。

让我用正确的 Redux 思维来为你重构这个问题,你会发现通讯变得非常简单和清晰。


正确的 Redux 结构:单一数据源

flowchart TD
    AppState["全局 AppState<br>包含三个页面的数据"]
    
    subgraph A [页面A]
        A_State[StateA]
        A_Action[ActionA]
    end

    subgraph B [页面B]
        B_State[StateB]
        B_Action[ActionB]
    end

    subgraph C [页面C]
        C_State[StateC]
        C_Action[ActionC]
    end

    AppState --> A_State
    AppState --> B_State
    AppState --> C_State

    A_Action -- dispatch --> Store
    B_Action -- dispatch --> Store
    C_Action -- dispatch --> Store

    Store -- 更新 --> AppState

实现步骤

第 1 步:定义全局的 State、Action 和 Reducer

State.swift - 单一数据源

// 整个应用只有一个根状态
struct AppState {
    // 页面A的状态,只是这个根状态的一个属性
    var pageAState: PageAState
    // 页面B的状态
    var pageBState: PageBState
    // 页面C的状态
    var pageCState: PageCState
    // 还可以有跨页面的共享状态
    var userIsLoggedIn: Bool
}

// 每个页面的状态仍然是独立的结构体,但被整合到AppState中
struct PageAState {
    var dataForA: String = ""
    var valueFromB: String? = nil // 用于接收来自B的数据
}

struct PageBState {
    var dataForB: Int = 0
    var valueFromC: String? = nil // 用于接收来自C的数据
}

struct PageCState {
    var dataForC: [String] = []
}

Action.swift - 统一的行为定义

// 所有页面的Action都集中在一个枚举中
enum AppAction {
    // 页面A的Action
    case pageA(PageAAction)
    case pageB(PageBAction)
    case pageC(PageCAction)
    // 全局的Action,如登录、登出
    case global(GlobalAction)
}

// 每个页面自己的Action枚举
enum PageAAction {
    case buttonTapped
    case dataLoaded(String)
    case receivedDataFromB(String) // 专门用于接收B的消息
}

enum PageBAction {
    case sliderValueChanged(Int)
    case sendDataToA(String) // 专门用于向A发送数据
}

enum PageCAction {
    case itemSelected(Int)
}

Reducer.swift - 统一的 reducer

// 根Reducer,负责组合所有页面的reducer
func appReducer(state: inout AppState, action: AppAction) -> Void {
    switch action {
    
    // 分解处理页面A的Action
    case .pageA(let pageAAction):
        pageAReducer(state: &state.pageAState, action: pageAAction)
        
    // 分解处理页面B的Action
    case .pageB(let pageBAction):
        pageBReducer(state: &state.pageBState, action: pageBAction)
        // B的Action可能会影响到其他页面!
        // 例如:当B发送数据时,需要更新A的状态
        if case .sendDataToA(let data) = pageBAction {
            state.pageAState.valueFromB = data // 直接修改A的状态
        }
        
    // 分解处理页面C的Action
    case .pageC(let pageCAction):
        pageCReducer(state: &state.pageCState, action: pageCAction)
        
    // 处理全局Action
    case .global(let globalAction):
        globalReducer(state: &state, action: globalAction)
    }
}

// 每个页面自己的reducer(纯函数)
func pageAReducer(state: inout PageAState, action: PageAAction) {
    switch action {
    case .buttonTapped:
        print("A的按钮被点击")
    case .dataLoaded(let data):
        state.dataForA = data
    case .receivedDataFromB(let dataFromB):
        state.valueFromB = dataFromB // 更新来自B的数据
    }
}

func pageBReducer(state: inout PageBState, action: PageBAction) {
    switch action {
    case .sliderValueChanged(let value):
        state.dataForB = value
    case .sendDataToA(let data):
        // 注意:这个Action的主要处理逻辑在根Reducer中
        // 这里可以处理B自身相关的状态更新
        print("B准备发送数据给A: \(data)")
    }
}

第 2 步:创建唯一的全局 Store

Store.swift

class Store: ObservableObject {
    @Published private(set) var state: AppState
    private let reducer: (inout AppState, AppAction) -> Void
    
    init(initialState: AppState, reducer: @escaping (inout AppState, AppAction) -> Void) {
        self.state = initialState
        self.reducer = reducer
    }
    
    func dispatch(_ action: AppAction) {
        reducer(&state, action)
    }
}

// 在应用入口创建唯一Store
let globalStore = Store(initialState: AppState(
    pageAState: PageAState(),
    pageBState: PageBState(), 
    pageCState: PageCState(),
    userIsLoggedIn: false
), reducer: appReducer)

第 3 步:在页面中使用全局 Store

PageAView.swift

struct PageAView: View {
    @EnvironmentObject var store: Store // 注入的是全局唯一的Store
    
    // 从全局State中取出页面A需要的部分状态
    private var pageAState: PageAState { store.state.pageAState }
    
    var body: some View {
        VStack {
            Text("页面A的数据: \(pageAState.dataForA)")
            // 显示从页面B传来的数据
            if let dataFromB = pageAState.valueFromB {
                Text("来自B的消息: \(dataFromB)")
            }
            Button("通知B") {
                // 派发Action,而不是直接调用B的方法
                store.dispatch(.pageB(.sendDataToA("你好,我是A!")))
            }
        }
    }
}

PageBView.swift

struct PageBView: View {
    @EnvironmentObject var store: Store // 同一个全局Store
    
    private var pageBState: PageBState { store.state.pageBState }
    
    var body: some View {
        VStack {
            Text("B的数值: \(pageBState.dataForB)")
            Button("发送数据到A") {
                // 通过全局Store派发Action
                store.dispatch(.pageB(.sendDataToA("Hello from B!")))
            }
            NavigationLink("去C") {
                PageCView()
            }
        }
    }
}

通信场景实现

现在,让我们看看如何实现具体的通信:

  1. A -> B 通信

    • A 中:store.dispatch(.pageB(.sendDataToA("你好,我是A!")))
    • 根Reducer 接收到 AppAction.pageB(.sendDataToA),它会: a. 调用 pageBReducer 处理 B 自身的状态(如果需要) b. 直接修改 state.pageAState.valueFromB
    • 由于 PageAView 依赖于 store.state.pageAState,SwiftUI 会自动重绘页面A,新的数据就显示出来了!
  2. B -> A 通信:(同上,方向相反)

  3. 任何页面 -> 全局状态

    • 任何页面都可以派发全局 Action:store.dispatch(.global(.loginSuccess))
    • 这会在根Reducer中处理,更新 state.userIsLoggedIn
    • 所有依赖 userIsLoggedIn 的页面都会自动更新!

总结

  • 只有一个 Store:这是 Redux 架构的绝对核心。
  • State 是组合的:每个页面的 State 是全局 AppState 的一个属性。
  • Action 是统一的:所有页面的 Action 都通过一个统一的枚举管理。
  • Reducer 是分形的:有一个根 Reducer,它负责将 Action 分发给各个页面的 Reducer 处理。
  • 通信方式:页面间通信就是派发一个目标为其他页面的 Action。这个 Action 会在根 Reducer 中被处理,并直接修改目标页面的 State

这种方式虽然初期需要更多样板代码,但带来的好处是巨大的:极其清晰的数据流、可预测的状态变化、易于调试和测试。所有页面间的耦合都被解除了,它们都只依赖于全局的 Store,而不是彼此。

Redux在iOS中的使用

作者 littleplayer
2025年9月14日 09:47

好的,我们来详细探讨一下 Redux 在 iOS 开发中的应用。Redux 是一个源自 Web 前端(通常与 React 搭配)的架构模式,它因其单一数据源、状态不可变和纯函数Reducer 等特性,在 iOS 开发中也获得了大量关注和实践。

Redux 核心概念回顾

理解 Redux 在 iOS 的实现,首先要理解其三个基本原则:

  1. 单一数据源 (Single Source of Truth): 整个应用的状态(State)被存储在一个单一的、中心化的 Store 对象中。这消除了状态分散在不同组件所带来的复杂性,使得状态的追踪和调试变得非常容易。

  2. 状态是只读的 (State is Read-Only): 唯一改变状态的方法就是派发一个 Action。Action 是一个简单的、描述“发生了什么”的对象(通常是结构体或枚举)。你不能直接修改状态,这保证了状态更新的可预测性。

  3. 使用纯函数进行更改 (Changes are Made with Pure Functions): 为了指定状态如何被 Action 转换,你需要编写 Reducers。Reducer 是一个纯函数,它接收当前的 State 和一个 Action,并返回一个新的、更新后的 State(而不是修改旧的 State)。


在 iOS 中的核心组件映射

Redux 概念 iOS 中的实现 说明
State 一个结构体 (struct) 或类 包含整个应用当前所有数据的模型。必须是值类型struct)以确保不可变性。
Action 一个枚举 (enum) 描述所有可能改变状态的事件。每个 case 可以关联一些数据。
Reducer 一个函数 (function) (State, Action) -> State。根据 Action 生成新 State 的纯函数。
Store 一个单例或通过依赖注入的类 (class) 持有当前 State;接收并派发 Action;运行 Reducer 来更新 State;通知观察者。
View UIViewControllerSwiftUI.View 观察 State 的变化并重新渲染 UI;向 Store 派发用户交互产生的 Action。

一个简单的计数器示例 (SwiftUI + Combine)

让我们用一个经典的计数器例子来演示如何在 iOS (SwiftUI) 中实现 Redux。

第 1 步:定义 State

// 应用的状态。必须是结构体,以保证不可变性。
struct AppState {
    var count: Int = 0
}

第 2 步:定义 Action

// 所有能改变状态的动作。
enum Action {
    case increment
    case decrement
    case incrementBy(Int) // 关联值
}

第 3 步:定义 Reducer

// 这是一个纯函数:相同的输入,永远得到相同的输出,且无副作用。
func appReducer(state: AppState, action: Action) -> AppState {
    var newState = state // 复制当前状态(因为 state 是 struct,是值类型)
    
    switch action {
    case .increment:
        newState.count += 1
    case .decrement:
        newState.count -= 1
    case .incrementBy(let amount):
        newState.count += amount
    }
    // 返回一个全新的状态对象
    return newState
}

第 4 步:创建 Store

这是最关键的一步。Store 是大脑,它协调所有操作。

import Combine

// Store 是一个 ObservableObject,这样 SwiftUI 视图才能观察它的变化。
class Store: ObservableObject {
    // 发布者:State 的变化会驱动 UI 更新
    @Published private(set) var state: AppState
    
    // Reducer 函数
    private let reducer: (AppState, Action) -> AppState
    
    init(initialState: AppState, reducer: @escaping (AppState, Action) -> AppState) {
        self.state = initialState
        self.reducer = reducer
    }
    
    // 唯一能改变状态的方法:派发 Action
    func dispatch(_ action: Action) {
        // 在主线程上同步更新状态,保证线程安全
        DispatchQueue.main.async {
            // 调用 reducer 生成新状态,并替换旧状态
            self.state = self.reducer(self.state, action)
            // 由于 @Published 属性发生变化,objectWillChange 会自动发出信号,
            // 通知所有观察的 View 更新。
        }
    }
}

第 5 步:在 SwiftUI View 中使用

struct CounterView: View {
    // 从环境中获取或直接注入 Store
    @EnvironmentObject var store: Store
    
    var body: some View {
        VStack {
            Text("Count: \(store.state.count)") // 从 Store 中读取状态
                .font(.largeTitle)
            
            HStack {
                // 向 Store 派发 Action
                Button("-") { store.dispatch(.decrement) }
                Button("+") { store.dispatch(.increment) }
                Button("+10") { store.dispatch(.incrementBy(10)) }
            }
            .buttonStyle(.borderedProminent)
        }
    }
}

第 6 步:在入口点设置 Store

@main
struct MyApp: App {
    // 创建全局唯一的 Store,并注入到环境中
    let store = Store(initialState: AppState(), reducer: appReducer)
    
    var body: some Scene {
        WindowGroup {
            CounterView()
                .environmentObject(store) // 注入 Store
        }
    }
}

处理副作用 (Side Effects)

上面的 Reducer 是纯的,但真实应用需要副作用(如网络请求、读写磁盘等)。纯函数不能处理这些。解决方案是使用 “Effect” 模式(这正是 The Composable Architecture (TCA) 等库的核心)。

  1. 让 Reducer 返回一个 Effect:Reducer 除了返回新 State,还返回一个描述副作用的 Effect 对象。
  2. Store 执行 Effect:Store 在运行 Reducer 后,会执行返回的 Effect(比如发起网络请求)。
  3. Effect 完成后派发新 Action:当网络请求完成时,Effect 会自动派发一个新的 Action(如 .dataLoaded(Result)),这个 Action 会再次通过 Reducer 来更新状态。

简化版的 Effect 示例:

// 1. 扩展 Action 来包含副作用结果
enum Action {
    case increment
    case fetchButtonTapped
    case dataLoaded(Result<Data, Error>)
}

// 2. Reducer 可以返回一个额外的 Effect
func appReducer(state: AppState, action: Action) -> (AppState, Effect<Action>?) {
    var newState = state
    var effect: Effect<Action>? = nil
    
    switch action {
    case .fetchButtonTapped:
        effect = Effect { // 返回一个发起网络请求的 Effect
            // 模拟网络请求
            let result = Result { try await fetchDataFromNetwork() }
            return Action.dataLoaded(result)
        }
    case .dataLoaded(.success(let data)):
        newState.data = data
    case .dataLoaded(.failure(let error)):
        newState.error = error
    ...
    }
    return (newState, effect)
}

// 3. Store 的 dispatch 方法需要处理返回的 Effect 并执行它。

在 UIKit 中的使用

在 UIKit 中,概念完全相同,但需要手动实现状态观察。

  1. Store 仍然是一个中心化的类。
  2. ViewControllers 需要订阅 Store 的状态变化(例如,使用 Combine 的 $state.sink {...})。
  3. 在订阅的闭包中,根据新的 State 来手动更新 UI(设置 label 的 text、刷新 table view 等)。
  4. 在 IBAction 或代理方法中,调用 store.dispatch(...)

优缺点分析

优点:

  • 可预测性:状态变化非常清晰,总是 Action -> Reducer -> New State
  • 可调试性:可以轻松记录和重放每一个 Action 和状态快照。
  • 可测试性:Reducer 是纯函数,极易测试。只需给定输入,断言输出。
  • 单一数据源:避免了状态在不同组件间同步的难题。

缺点:

  • 样板代码 (Boilerplate):需要为每个功能定义 State, Action, Reducer,略显繁琐。
  • 学习曲线:对于新手来说,概念相对复杂。
  • 性能:对于非常庞大的状态树,频繁复制整个 state 可能带来性能开销(但通常不是问题)。

总结与建议

  • 对于简单应用:直接使用 @PublishedObservableObject 可能更轻量。
  • 对于中大型复杂应用:Redux 架构能极大地提升代码的可维护性和可预测性。
  • 推荐使用库:手动实现完整的 Redux 和副作用处理比较复杂。强烈推荐使用 The Composable Architecture (TCA),它是一个非常成熟、强大的 Swift 库,完美实现了 Redux 模式,并提供了出色的工具和测试支持。它大大减少了样板代码,是 iOS 上实践 Redux 的最佳选择。
昨天以前掘金 iOS

在同步代码里调用 async/await:Task 就是你的“任意门”

作者 unravel2025
2025年9月12日 19:51

场景:同步上下文想调异步函数

func fetchData() async -> String { /* 网络请求 */ }

struct ContentView: View {
    var body: some View {
        Button("Load") {
            await fetchData()   // ❌ 编译错误:同步闭包里不能用 await
        }
    }
}

错误提示:

Cannot pass function of type '() async -> Void' to parameter expecting synchronous function type

官方逃生舱:包一层 Task {}

Button("Load") {
    Task {                      // ✅ 立即启动异步任务
        let data = await fetchData()
        print(data)
    }
}
  • Task 会立刻在新协程里执行闭包,不要求外部上下文支持并发。
  • 无需手动持有 Task 实例,除非你想取消或等待它完成。

Task 的 3 种常见“同步→异步”桥梁模式

模式 代码片段 用途
Fire-and-forget Task { await work() } 按钮点击、日志上报
取消友好 Task { [weak self] in … } ViewController/View 生命周期
Delegate/App 生命周期 Task { await requestPermissions() } application(_:didFinishLaunchingWithOptions:)

实战 1:带取消的 SwiftUI 任务

struct ContentView: View {
    @State private var task: Task<Void, Never>?   // 1️⃣ 持有任务
    
    var body: some View {
        VStack {
            Button("Start") {
                task = Task {                     // 2️⃣ 创建并保存
                    let data = await fetchData()
                    print(data)
                }
            }
            Button("Cancel") {
                task?.cancel()                    // 3️⃣ 外部取消
                task = nil
            }
        }
        .onDisappear {
            task?.cancel()                        // 4️⃣ 生命周期清理
        }
    }
}

记住:视图消失时必须取消,否则后台任务可能访问已销毁的 @State

实战 2:AppDelegate 里请求推送权限

class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        
        // 同步方法内直接启动异步任务
        Task {
            do {
                try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])
                DispatchQueue.main.async {
                    application.registerForRemoteNotifications()
                }
            } catch {
                print("权限请求失败: \(error)")
            }
        }
        
        return true
    }
}
  • Task 让“老派”的同步生命周期钩子也能享受结构化并发。
  • 不需要 awaitapplication(_:didFinishLaunchingWithOptions:) 返回值里,启动即忘即可。

闭包捕获注意事项

Button("Load") {
    Task { [weak self] in          // ✅ 防止循环引用
        guard let self else { return }
        self.model.data = await self.fetchData()
    }
}

在 View/ViewController/ViewModel 里使用 Task 时,养成 [weak self] 习惯,避免闭包持有整个视图层级。

一句话总结

“同步上下文想 await?包一层 Task {} 就行。”

它是 Swift 结构化并发的任意门:轻量、无样板、可取消。

只要记得生命周期对齐 + 弱引用捕获,就能在按钮、Delegate、App 生命周期里放心使用 async/await。

参考资料

  1. How to use async/await in synchronous Swift code with tasks

Swift 三目运算符指南:写法、场景与避坑

作者 unravel2025
2025年9月12日 12:32

什么是三目运算符?

三目运算符(ternary operator)是 if/else 的单行表达式版,语法模板:

<条件> ? <条件为真结果> : <条件为假结果>

必须同时给出真/假两个分支,否则编译器直接报错。

最小可运行示例

struct DemoView: View {
    @State private var username = ""

    var body: some View {
        Button("Submit") {}
            .tint(username.isEmpty ? .gray : .red)   // ← 三目
    }
}

username 为空时按钮呈灰色,否则红色。

一行代码完成“判断 + 赋值”,是 SwiftUI 里高频用法。

适用场景 checklist

✅ 推荐

  • 仅两个分支
  • 每个分支单个表达式
  • 表达式短且无副效应
  • 作为参数/赋值使用

❌ 不推荐

  • 嵌套三目 → 可读性断崖
  • 分支里再调函数/打印/网络请求
  • 一行超长 120+ 字符

if 表达式对比(Swift 5.9+)

Swift 5.9 起,if 也能当表达式用:

let color: Color = if username.isEmpty {
    .gray
} else {
    .red
}
维度 三目 if表达式
行数 1 行 多行
可读性 简洁但易过长 清晰
分支数 仅 2 可 else if
使用位置 任意表达式上下文 只能用于赋值/返回
嵌套 容易失控 结构清晰

结论:

  • 简单二选一 → 三目
  • 需要 else if / 长表达式 → if 表达式
  • 副作用或复杂逻辑 → 普通 if 语句

实战:SwiftUI 里常用的一行代码

Image(systemName: isOn ? "checkmark.circle.fill" : "circle")
    .foregroundColor(isOn ? .green : .gray)

Text("Remain: \(secondsLeft > 0 ? "\(secondsLeft)s" : "Done")")
    .fontWeight(secondsLeft > 0 ? .regular : .bold)

Circle()
    .fill(status == .loading ? AnyShapeStyle(.orange) : AnyShapeStyle(.blue))

踩坑指南

  1. 类型必须一致
   let x = flag ? 1 : 1.0        // ❌ 编译错误:Int vs Double
   let x = flag ? Double(1) : 1.0 // ✅
  1. 优先级陷阱
   print(false ? "A" : "B" + "!")   // 输出 B!,+ 优先级高于三目

推荐加括号:

   print((false ? "A" : "B") + "!")
  1. 嵌套地狱
   let color = a ? (b ? .red : .green) : (c ? .blue : .gray)

超过一层嵌套,立即改成 if 表达式或工厂方法。

小结:一句话口诀

“二选一、短表达式、只取值,用三目;否则换 if。”

把三目当作“单行表达式糖”,而非“万能 if”,就能在简洁与可读之间找到最佳平衡点。祝你写出既短又稳的 Swift 代码!

一篇读懂 Swift 不透明类型:让带 associatedtype 的协议也能当返回值

作者 unravel2025
2025年9月12日 10:04

参考原文:Understanding opaque types and protocols with associatedtype in Swift

环境:Swift 6.2 + Xcode 26

why:带 associatedtype 的协议为何不能当返回值?

protocol Store {
    associatedtype Item
    func persist(item: Item)
}

// ❌ 编译失败:Protocol 'Store' can only be used as a generic constraint
func makeStore() -> Store { ... }
  • associatedtype 未被确定 → 编译期无法决定具体内存布局。
  • Swift 拒绝“协议当作类型”使用,除非用泛型或 opaque 类型。

传统 workaround:泛型约束

func makeStore<T: Store>() -> T { ... }   // ✅ 可行,但调用端要写类型

痛点:

  • 调用处仍需显式指定类型
  • 代码膨胀(每种 T 一份实现)
  • 无法隐藏实现细节(返回类型泄露)

Swift 5.1+ 解法:opaque 类型 (some)

func makeStore() -> some Store { 
    return UserDefaultsStore()   // 具体类型被隐藏,调用端只认 Store 协议
}
  • 返回类型由编译器推断,调用者无需知道 UserDefaultsStore
  • 内存布局确定(编译期知道真实类型大小)。
  • 语法糖:等价于“泛型参数由编译器自动填充”。

opaque vs 泛型 vs 存在容器(any)速查表

特性 具体类型 内存布局 性能 隐藏实现 调用端写法 适用场景
opaque (some) 编译期已知 静态派发,无额外开销 最优 最简洁 返回值/参数想隐藏具体类型
泛型 <T: Store> 调用者指定 静态 最优 需显式类型 需要多类型复用实现
存在容器 (any Store) 运行时动态 存在容器(1 ptr + metadata) 动态派发,略慢 同 opaque 需要运行时异构集合

实战:同一函数三种写法对比

// 1. 泛型 — 调用者决定类型
func makeStore<T: Store>() -> T { T() }

// 2. Opaque — 实现者决定类型,调用者无感
func makeStore() -> some Store { UserDefaultsStore() }

// 3. 存在容器 — 运行时多态
func makeStore() -> any Store { UserDefaultsStore() }

调用侧:

let s1: some Store = makeStore()   // 编译期知道真实类型
let s2: any Store  = makeStore()   // 运行时才知道

什么时候选 opaque?

  1. 只想隐藏返回类型,不关心具体实现
  2. 性能敏感(避免存在容器额外间接层)
  3. API 向前兼容——日后可无缝换成别的具体类型,不破坏二进制接口

一句话总结

带 associatedtype 的协议不能当返回值?

some Protocol 就行!

它 = “编译期泛型” + “实现细节隐藏” + “零成本抽象”,

让协议真正像“类型”一样使用,而无需把泛型复杂性抛给调用者。

`@dynamicCallable`:把 Swift 对象当函数喊

作者 unravel2025
2025年9月12日 10:02

一、为什么需要“假装函数”?

有时我们想让一个值看起来就是函数,从而写出更自然的 DSL:

logger("App launched")           // 像 print
let person = creator(name: "A")  // 像工厂

@dynamicCallable 就是 Swift 给的“变身器”: “让实例像函数一样被 call,背后转到你定义的方法。”

二、核心机制:两条魔法方法

方法 对应调用语法 参数类型
dynamicallyCall(withArguments:) instance(a, b, c) [T]
dynamicallyCall(withKeywordArguments:) instance(name: x, age: y) KeyValuePairs<String, T>

只需实现任意一个或两个,即可开启 callable 语法。

三、最小可运行示例:Hello Greeter

  1. 传统写法
struct Greeter {
    func sayHello(to name: String) -> String {
        "Hello, \(name)!"
    }
}
let g = Greeter()
g.sayHello(to: "Alice")
  1. @dynamicCallable 变身
@dynamicCallable
struct Greeter {
    func dynamicallyCall(withArguments names: [String]) -> String {
        guard let first = names.first else { return "Hello, World!" }
        return "Hello, \(first)!"
    }
}

let g = Greeter()
g("Alice")        // "Hello, Alice!"
g()               // "Hello, World!"

变化:

g.sayHello(to:) → 直接 g(...),更像函数。

四、带标签参数:KeyValuePairs 实战

@dynamicCallable
struct PersonCreator {
    func dynamicallyCall(withKeywordArguments args: KeyValuePairs<String, String>) -> String {
        args.map { "\($0) is \($1)" }.joined(separator: ", ")
    }
}

let creator = PersonCreator()
creator(name: "John")                    // "name is John"
creator(name: "Alice", age: "25", city: "NYC") // "name is Alice, age is 25, city is NYC"

KeyValuePairs 保持标签顺序,比 Dictionary 更适合 DSL。

五、真实场景:可调用 Logger

@dynamicCallable
struct Logger {
    func dynamicallyCall(withArguments msgs: [String]) {
        print("[\(Date())] \(msgs.joined(separator: " "))")
    }
    func dynamicallyCall(withKeywordArguments args: KeyValuePairs<String, String>) {
        let pairs = args.map { "\($0): \($1)" }.joined(separator: ", ")
        print("[\(Date())] \(pairs)")
    }
}

let log = Logger()
log("App", "started")                       // 简写
log(event: "login", user: "john", status: "ok")  // 结构化

输出:

[2025-09-05 14:22:10 +0000] App started
[2025-09-05 14:22:10 +0000] event: login, user: john, status: ok

六、与 Swift 6 并发兼容

@dynamicCallable 方法默认继承调用者的隔离域:

@MainActor
class ViewModel {
    @dynamicCallable
    struct Logger {
        func dynamicallyCall(withArguments msgs: [String]) {
            print("[Main] \(msgs.joined())")
        }
    }
    
    func tap() {
        let log = Logger()
        log("Button tapped")   // 主线程执行,安全
    }
}

→ 无需额外标注,自动遵循隔离规则。

七、什么时候用 / 不用

✅ 适合

  • 构建DSL(日志、配置、SQL、Shell)
  • 希望 API 像函数一样自然
  • 参数数量或标签不固定

❌ 不适合

  • 普通业务逻辑——直接方法更清晰
  • 需要强类型检查(编译期无法看到具体标签)
  • 团队对“魔法”语法接受度低

八、常见编译错误对照

错误原因修复 Member dynamicallyCall has unsupported type方法签名不对改为官方模板 [T]KeyValuePairs<String, T> Call arguments don't match any overload参数类型/数量不符检查实参类型与 withArguments/withKeywordArguments 是否一致 Cannot call value of non-function type忘记加 @dynamicCallable补上属性


九、小结:一句话背下来

@dynamicCallable = “把实例当函数喊”,背后转到你写的 dynamicallyCall

它让 API 更自然、让 DSL 更优雅,但也别滥用——清晰比酷炫更重要。

记住口诀:

“要 callable,加 @dynamicCallable; positional 用数组,labeled 用 KeyValuePairs。”

下次写配置、日志、DSL 时,不妨让它“像个函数”——一声 call,就搞定。

仓颉语言Option 的“问号”全景图—— 一个 `?` 与 `.` `()` `[]` `{}` 的组合写法

作者 unravel2025
2025年9月12日 09:59

一句话先给结论

在仓颉里,? 是 Option 的“安全导航符”;它能无缝插进任何“取值/调用/下标/代码块”场景,遇到 None 就立即短路返回 None,否则继续往后走。

下面带你一次看全所有花式用法。

基础规则速查表

符号 意义 是否可接 ? 短路行为
. 成员访问 None 时跳过成员访问
() 函数/方法调用 None 时跳过调用
[] 下标取值 None 时跳过取下标
{} lambda/闭包 None 时跳过闭包执行

场景示例

方法返回Option类型

func readFile(): ?String {
    None<String>
}
let cache: Option<String> = readFile()

?. 访问成员变量

import std.random.Random

let rand = Random()

struct User {
    User(let name: String) {}
}

func getUser(): ?User {
    if (rand.nextBool()) {
        User("unravel")
    } else {
        None
    }
}

let u: Option<User> = getUser() // 可能 None
let len = u?.name.size // Option<Int>

?() 调用函数

func twice(x: Int64): Int64 {
    x * 2
}

let f: Option<(Int64) -> Int64> = Some(twice)
let r = f?(10) // Some(20)

?[] 访问下标

// 安全下标
let arr: Option<Array<Int64>> = Some([7, 8, 9])
// arr存在时才可以返回值,不能保证下标越界的崩溃
let second = arr?[1] // Some(8)

?{} 传入尾随闭包

type IntCallFunc = ((Int64) -> Int64) -> Int64

func opFunc(action: (Int64) -> Int64): Int64 {
    action(5)
}

let op: ?IntCallFunc = opFunc
let doubled = op? {
    x => x * 2
} // Some(10)

链式混写——一次写完所有符号

type IntCallFunc = ((Int64) -> Int64) -> Int64

func opFunc(action: (Int64) -> Int64): Int64 {
    action(5)
}
class ContainerItem {
    func compute(): ?IntCallFunc {
        return opFunc
    }
}

class Container {
    Container(public let items: Array<?ContainerItem>) {}
}

// 链式混写——一次写完所有符号
let deep: Option<Container> = Container([ContainerItem()])
// 安全导航:取值→下标→调用→闭包
let result = deep?.items[0]?.compute()? {x => x + 1}

与 match 组合:把最终 None 转成默认值

当然,更建议使用coalescing操作符。coalescing操作符和下面的实现等价

let final0 = result ?? -1

let final = match (result) {
    case Some(v) => v
    case _ => -1
}

配合标准库ifSome、ifNone使用

ifSome(cache) {
    c => println("拿到缓存 ${c}")
}

ifNone(cache) {
    println("没有拿到缓存")
}

多层嵌套 struct 一路点下去

struct A {
    A(let b: Option<B>) {}
}

struct B {
    B(let c: Option<C>) {}
}

struct C {
    C(let value: Int64) {}
}

let a: Option<A> = A(B(C(64)))
let v = a?.b?.c?.value // Option<Int64>

数组元素本身是 Option

let opts: Array<Option<Int64>> = [Some(1), None, Some(3)]
let heads = opts[1] // 先取下标 → 得到 None
ifSome(heads) {
    v => println("heads的值是${v}")
}

高阶函数指针

type Fn = (Int64) -> Option<Int64>

let maybeFn: Option<Fn> = Some({x => Some(x * 3)})
let out = maybeFn?(7) // Some(21)

自定义下标运算符

extend<T> Array<T> {
    public operator func [](idx: Int64, action: (T) -> T): T {
        if (idx >= 0 && idx < size) {
            let v = this[idx]
            action(v)
        } else {
            throw Exception("下标越界")
        }
    }
} // Some(30)

let data = Some([10, 20])
let x = data?[1, {
        v => v + 10
    }]

一张图记住所有写法

Option<T> 变量 ──→ ? ──→ .member     → Option<U>
                 │     │()
                 │     │[]
                 │     │{ ... }
                 │     │
                 └─→ 任意一环 None 就整体返回 None

iOS26适配指南之UISlider

作者 YungFan
2025年9月12日 09:00

介绍

在 iOS 26 中,UISlider 迎来了两项重要更新:

  • 增加了类型为UISlider.Style的属性sliderStyle,用于设置拖拽时的样式。
  • 增加了类型为UISlider.TrackConfiguration?的属性trackConfiguration,用于添加刻度,并且支持自定义刻度。

这两个属性结合使用,可以让 UISlider 从传统的“连续滑块”进化为带刻度的选择器,常见于音量调节、亮度调节、进度选择、配置项选择等场景。

使用

  • 代码。
import UIKit

class ViewController: UIViewController {
    lazy var basicTickSlider: UISlider = {
        let slider = UISlider()
        slider.value = 0.5
        slider.addTarget(self, action: #selector(valueChanged), for: .valueChanged)
        // iOS26新增
        slider.sliderStyle = .default
        // iOS26新增,刻度数量
        var config = UISlider.TrackConfiguration(numberOfTicks: 10)
        config.allowsTickValuesOnly = true
        slider.trackConfiguration = config
        slider.translatesAutoresizingMaskIntoConstraints = false
        return slider
    }()
    lazy var customTickSlider: UISlider = {
        let slider = UISlider()
        slider.value = 0.5
        slider.addTarget(self, action: #selector(valueChanged), for: .valueChanged)
        slider.sliderStyle = .thumbless
        // iOS26新增,自定义刻度
        let customTicks = [
            UISlider.TrackConfiguration.Tick(position: 0),
            UISlider.TrackConfiguration.Tick(position: 0.1),
            UISlider.TrackConfiguration.Tick(position: 0.3),
            UISlider.TrackConfiguration.Tick(position: 0.6),
            UISlider.TrackConfiguration.Tick(position: 1.0)
        ]
        let config = UISlider.TrackConfiguration(allowsTickValuesOnly: true, ticks: customTicks)
        slider.trackConfiguration = config
        slider.translatesAutoresizingMaskIntoConstraints = false
        return slider
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(basicTickSlider)
        view.addSubview(customTickSlider)

        NSLayoutConstraint.activate([
            basicTickSlider.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 40),
            basicTickSlider.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            basicTickSlider.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
            basicTickSlider.heightAnchor.constraint(equalToConstant: 44),
            customTickSlider.topAnchor.constraint(equalTo: basicTickSlider.bottomAnchor, constant: 40),
            customTickSlider.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            customTickSlider.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20)
        ])
    }

    // MARK: 滑块内容变化事件
    @objc func valueChanged(_ sender: Any) {
        print(#function)
    }
}
  • 效果。

效果.gif

一文精通-Flutter 状态管理

2025年9月12日 08:51

什么是状态管理?

在 Flutter 中,状态是指任何可以随时间变化的数据,这些数据的变化会影响用户界面的呈现。状态管理则是处理这些数据的变更、存储、传递以及在UI上反映这些变更的一套方法和架构模式。

状态类型

  1. 局部状态(Ephemeral State) :只影响单个组件或少数几个组件的状态,通常使用 setState() 管理
  2. 应用状态(App State) :需要在多个部分之间共享的全局状态,需要专门的状态管理方案

常用状态管理方案详解

1. setState - 内置基础方案

setState 是 Flutter 最基础的状态管理方式,适用于组件内部的状态管理。

实现原理

通过调用 setState() 方法通知框架当前对象的状态已改变,需要重新构建组件树。

适用场景

  • 单个组件内部的简单状态
  • 不需要跨组件共享的状态
  • 简单的计数器、开关状态等

完整示例代码

dart

import 'package:flutter/material.dart';

// 使用setState管理的计数器应用
class CounterApp extends StatefulWidget {
  @override
  _CounterAppState createState() => _CounterAppState();
}

class _CounterAppState extends State<CounterApp> {
  int _counter = 0; // 定义状态变量

  // 增加计数器的方法
  void _incrementCounter() {
    setState(() { // 调用setState通知框架状态变化
      _counter++; // 更新状态值
    });
  }

  // 减少计数器的方法
  void _decrementCounter() {
    setState(() {
      _counter--; // 更新状态值
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('setState状态管理示例'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              '当前计数:',
              style: TextStyle(fontSize: 20),
            ),
            Text(
              '$_counter', // 显示状态值
              style: TextStyle(fontSize: 40, fontWeight: FontWeight.bold),
            ),
            SizedBox(height: 20),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: _decrementCounter, // 绑定减少方法
                  child: Icon(Icons.remove),
                ),
                SizedBox(width: 20),
                ElevatedButton(
                  onPressed: _incrementCounter, // 绑定增加方法
                  child: Icon(Icons.add),
                ),
              ],
            )
          ],
        ),
      ),
    );
  }
}

// 应用入口
void main() {
  runApp(MaterialApp(
    home: CounterApp(),
    debugShowCheckedModeBanner: false,
  ));
}

优点

  • 无需额外依赖
  • 简单易用,学习成本低
  • 适合简单场景

缺点

  • 状态无法在组件间轻松共享
  • 业务逻辑和UI代码混合,难以维护复杂应用
  • 性能较差,每次调用都会重建整个组件

2. Provider - 推荐的中等复杂度方案

Provider 是 Flutter 团队推荐的状态管理方案,基于 InheritedWidget 进行了封装简化。

添加依赖

yaml

dependencies:
  provider: ^6.0.0

核心概念

  • ChangeNotifier: 发布变更通知的类
  • ChangeNotifierProvider: 向子树提供ChangeNotifier的widget
  • Consumer: 监听Provider变化的widget
  • Selector: 只监听特定部分变化的Consumer优化版本

适用场景

  • 中小型应用
  • 需要跨组件共享状态的场景
  • 团队熟悉响应式编程概念

完整示例代码

dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

// 1. 创建数据模型,继承ChangeNotifier
class CounterModel with ChangeNotifier {
  int _count = 0;
  
  int get count => _count; // 获取状态值
  
  // 增加计数
  void increment() {
    _count++;
    notifyListeners(); // 通知监听者状态已改变
  }
  
  // 减少计数
  void decrement() {
    _count--;
    notifyListeners();
  }
  
  // 重置计数
  void reset() {
    _count = 0;
    notifyListeners();
  }
}

// 2. 在应用顶层提供状态
void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CounterModel(), // 创建状态实例
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Provider示例',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: HomePage(),
    );
  }
}

// 3. 主页
class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Provider状态管理'),
        actions: [
          IconButton(
            icon: Icon(Icons.refresh),
            onPressed: () {
              // 通过Provider.of访问状态方法,listen: false表示不监听变化
              Provider.of<CounterModel>(context, listen: false).reset();
            },
          )
        ],
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('使用Provider管理的计数器', style: TextStyle(fontSize: 18)),
            SizedBox(height: 20),
            // 4. 使用Consumer监听状态变化
            Consumer<CounterModel>(
              builder: (context, counter, child) {
                return Text(
                  '${counter.count}',
                  style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
                );
              },
            ),
            SizedBox(height: 20),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: () {
                    Provider.of<CounterModel>(context, listen: false).decrement();
                  },
                  child: Icon(Icons.remove),
                ),
                SizedBox(width: 20),
                ElevatedButton(
                  onPressed: () {
                    Provider.of<CounterModel>(context, listen: false).increment();
                  },
                  child: Icon(Icons.add),
                ),
              ],
            ),
            SizedBox(height: 30),
            // 5. 导航到另一个页面演示状态共享
            ElevatedButton(
              onPressed: () {
                Navigator.push(
                  context,
                  MaterialPageRoute(builder: (context) => SecondPage()),
                );
              },
              child: Text('前往第二页查看共享状态'),
            )
          ],
        ),
      ),
    );
  }
}

// 6. 第二个页面,演示状态共享
class SecondPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('第二页')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('这是第二页,显示相同的计数器状态', style: TextStyle(fontSize: 18)),
            SizedBox(height: 20),
            // 在不同页面使用同一个状态
            Consumer<CounterModel>(
              builder: (context, counter, child) {
                return Text(
                  '${counter.count}',
                  style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
                  textAlign: TextAlign.center,
                );
              },
            ),
            SizedBox(height: 20),
            Text('注意: 两页面的计数器状态是同步的', style: TextStyle(color: Colors.grey)),
          ],
        ),
      ),
    );
  }
}

优点

  • Flutter官方推荐
  • 概念简单,易于学习
  • 性能良好,可以精确控制重建范围
  • 适合大多数应用场景

缺点

  • 需要一定的样板代码
  • 对于超大型应用可能不够强大

3. Bloc - 复杂应用的状态管理

Bloc (Business Logic Component) 使用流(Stream)来管理状态,采用单向数据流架构。

添加依赖

yaml

dependencies:
  flutter_bloc: ^8.0.0
  equatable: ^2.0.0

核心概念

  • Event: 表示用户交互或系统事件
  • State: 应用的状态
  • Bloc: 将Event转换为State的业务逻辑组件

适用场景

  • 中大型复杂应用
  • 需要严格分离业务逻辑和UI
  • 需要高度可测试性的项目
  • 需要时间旅行调试等高级功能

完整示例代码

dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';

// 1. 定义事件(Event)
abstract class CounterEvent extends Equatable {
  const CounterEvent();

  @override
  List<Object> get props => [];
}

class IncrementEvent extends CounterEvent {} // 增加事件
class DecrementEvent extends CounterEvent {} // 减少事件
class ResetEvent extends CounterEvent {}     // 重置事件

// 2. 定义状态(State)
class CounterState extends Equatable {
  final int count;
  
  const CounterState(this.count);
  
  // 命名构造函数,提供初始状态
  const CounterState.initial() : count = 0;
  
  @override
  List<Object> get props => [count];
  
  // 重写toString方便调试
  @override
  String toString() => 'CounterState(count: $count)';
}

// 3. 创建Bloc(业务逻辑组件)
class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc() : super(const CounterState.initial()) {
    // 注册事件处理器
    on<IncrementEvent>(_onIncrement);
    on<DecrementEvent>(_onDecrement);
    on<ResetEvent>(_onReset);
  }
  
  // 处理增加事件
  void _onIncrement(IncrementEvent event, Emitter<CounterState> emit) {
    emit(CounterState(state.count + 1));
  }
  
  // 处理减少事件
  void _onDecrement(DecrementEvent event, Emitter<CounterState> emit) {
    emit(CounterState(state.count - 1));
  }
  
  // 处理重置事件
  void _onReset(ResetEvent event, Emitter<CounterState> emit) {
    emit(const CounterState.initial());
  }
}

// 4. 应用入口
void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Bloc示例',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: BlocProvider(
        // 提供Bloc实例
        create: (context) => CounterBloc(),
        child: const HomePage(),
      ),
    );
  }
}

// 5. 主页
class HomePage extends StatelessWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // 获取Bloc实例
    final CounterBloc counterBloc = BlocProvider.of<CounterBloc>(context);
    
    return Scaffold(
      appBar: AppBar(
        title: const Text('Bloc状态管理'),
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: () {
              // 发送重置事件
              counterBloc.add(ResetEvent());
            },
          )
        ],
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('使用Bloc管理的计数器', style: TextStyle(fontSize: 18)),
            const SizedBox(height: 20),
            // 6. 使用BlocBuilder构建响应UI
            BlocBuilder<CounterBloc, CounterState>(
              builder: (context, state) {
                return Text(
                  '${state.count}',
                  style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
                );
              },
            ),
            const SizedBox(height: 20),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: () {
                    // 发送减少事件
                    counterBloc.add(DecrementEvent());
                  },
                  child: const Icon(Icons.remove),
                ),
                const SizedBox(width: 20),
                ElevatedButton(
                  onPressed: () {
                    // 发送增加事件
                    counterBloc.add(IncrementEvent());
                  },
                  child: const Icon(Icons.add),
                ),
              ],
            ),
            const SizedBox(height: 30),
            ElevatedButton(
              onPressed: () {
                Navigator.push(
                  context,
                  MaterialPageRoute(builder: (context) => const SecondPage()),
                );
              },
              child: const Text('前往第二页查看共享状态'),
            )
          ],
        ),
      ),
    );
  }
}

// 7. 第二个页面
class SecondPage extends StatelessWidget {
  const SecondPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('第二页')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('这是第二页,显示相同的计数器状态', style: TextStyle(fontSize: 18)),
            const SizedBox(height: 20),
            // 在不同页面共享同一个Bloc状态
            BlocBuilder<CounterBloc, CounterState>(
              builder: (context, state) {
                return Text(
                  '${state.count}',
                  style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
                  textAlign: TextAlign.center,
                );
              },
            ),
            const SizedBox(height: 20),
            const Text('注意: 两页面的计数器状态是同步的', style: TextStyle(color: Colors.grey)),
          ],
        ),
      ),
    );
  }
}

优点

  • 严格的关注点分离
  • 极高的可测试性
  • 强大的调试工具(Bloc Observer)
  • 适合大型团队协作

缺点

  • 学习曲线较陡峭
  • 需要较多样板代码
  • 概念较多,初学者可能难以理解

4. GetX - 轻量且功能强大的方案

GetX 是一个轻量且强大的解决方案,不仅提供状态管理,还提供路由管理、依赖注入等功能。

添加依赖

yaml

dependencies:
  get: ^4.6.1

核心概念

  • GetxController: 管理状态和业务逻辑的控制器
  • Obx: 响应式观察者组件
  • GetBuilder: 非响应式状态更新组件
  • Get.put: 依赖注入方法

适用场景

  • 希望尽量减少样板代码的项目
  • 需要轻量级但功能全面的解决方案
  • 中小型应用快速开发

完整示例代码

dart

import 'package:flutter/material.dart';
import 'package:get/get.dart';

// 1. 创建控制器类
class CounterController extends GetxController {
  // 使用Rx包装状态使其可观察
  var count = 0.obs;
  
  // 增加计数
  void increment() {
    count.value++;
  }
  
  // 减少计数
  void decrement() {
    count.value--;
  }
  
  // 重置计数
  void reset() {
    count.value = 0;
  }
}

// 2. 应用入口
void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 初始化控制器
    final CounterController counterController = Get.put(CounterController());
    
    return GetMaterialApp( // 使用GetMaterialApp替代MaterialApp
      title: 'GetX示例',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: HomePage(),
    );
  }
}

// 3. 主页
class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('GetX状态管理'),
        actions: [
          IconButton(
            icon: Icon(Icons.refresh),
            onPressed: () {
              // 获取控制器并调用方法
              Get.find<CounterController>().reset();
            },
          )
        ],
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('使用GetX管理的计数器', style: TextStyle(fontSize: 18)),
            SizedBox(height: 20),
            // 4. 使用Obx构建响应式UI
            Obx(() {
              return Text(
                '${Get.find<CounterController>().count.value}',
                style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
              );
            }),
            SizedBox(height: 20),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: () {
                    Get.find<CounterController>().decrement();
                  },
                  child: Icon(Icons.remove),
                ),
                SizedBox(width: 20),
                ElevatedButton(
                  onPressed: () {
                    Get.find<CounterController>().increment();
                  },
                  child: Icon(Icons.add),
                ),
              ],
            ),
            SizedBox(height: 30),
            ElevatedButton(
              onPressed: () {
                // 使用GetX导航,无需context
                Get.to(SecondPage());
              },
              child: Text('前往第二页查看共享状态'),
            )
          ],
        ),
      ),
    );
  }
}

// 5. 第二个页面
class SecondPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('第二页')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('这是第二页,显示相同的计数器状态', style: TextStyle(fontSize: 18)),
            SizedBox(height: 20),
            // 在不同页面共享同一个状态
            Obx(() {
              return Text(
                '${Get.find<CounterController>().count.value}',
                style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
                textAlign: TextAlign.center,
              );
            }),
            SizedBox(height: 20),
            Text('注意: 两页面的计数器状态是同步的', style: TextStyle(color: Colors.grey)),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                // 返回上一页
                Get.back();
              },
              child: Text('返回'),
            ),
          ],
        ),
      ),
    );
  }
}

优点

  • 极少的样板代码
  • 性能优异,精确更新
  • 集成路由、依赖注入等功能
  • 学习曲线平缓

缺点

  • 不符合Flutter传统模式
  • 可能过度封装,隐藏了Flutter底层机制
  • 在超大型项目中可能难以维护

5. Riverpod - Provider的改进版

Riverpod 是 Provider 的改进版本,解决了 Provider 的一些限制,如编译安全、无需BuildContext等。

添加依赖

yaml

dependencies:
  flutter_riverpod: ^1.0.3

核心概念

  • Provider: 各种提供者的基类
  • StateProvider: 提供简单可变状态
  • StateNotifierProvider: 提供更复杂的状态和业务逻辑
  • ConsumerWidget/Consumer: 消费Provider的组件

适用场景

  • 所有规模的应用
  • 需要编译时安全的状态管理
  • 希望避免Provider的某些限制

完整示例代码

dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// 1. 创建Provider
final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
  return CounterNotifier();
});

// 2. 创建Notifier类
class CounterNotifier extends StateNotifier<int> {
  CounterNotifier() : super(0); // 初始化状态
  
  // 增加计数
  void increment() {
    state++;
  }
  
  // 减少计数
  void decrement() {
    state--;
  }
  
  // 重置计数
  void reset() {
    state = 0;
  }
}

// 3. 应用入口
void main() {
  runApp(ProviderScope(child: MyApp())); // 使用ProviderScope包裹应用
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Riverpod示例',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: HomePage(),
    );
  }
}

// 4. 主页 - 使用ConsumerWidget替代StatelessWidget
class HomePage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 通过ref读取Provider状态
    final counter = ref.watch(counterProvider);
    // 获取Notifier实例用于调用方法
    final counterNotifier = ref.read(counterProvider.notifier);
    
    return Scaffold(
      appBar: AppBar(
        title: Text('Riverpod状态管理'),
        actions: [
          IconButton(
            icon: Icon(Icons.refresh),
            onPressed: () {
              counterNotifier.reset();
            },
          )
        ],
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('使用Riverpod管理的计数器', style: TextStyle(fontSize: 18)),
            SizedBox(height: 20),
            Text(
              '$counter', // 直接使用状态值
              style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
            ),
            SizedBox(height: 20),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: () {
                    counterNotifier.decrement();
                  },
                  child: Icon(Icons.remove),
                ),
                SizedBox(width: 20),
                ElevatedButton(
                  onPressed: () {
                    counterNotifier.increment();
                  },
                  child: Icon(Icons.add),
                ),
              ],
            ),
            SizedBox(height: 30),
            ElevatedButton(
              onPressed: () {
                Navigator.push(
                  context,
                  MaterialPageRoute(builder: (context) => SecondPage()),
                );
              },
              child: Text('前往第二页查看共享状态'),
            )
          ],
        ),
      ),
    );
  }
}

// 5. 第二个页面
class SecondPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final counter = ref.watch(counterProvider);
    
    return Scaffold(
      appBar: AppBar(title: Text('第二页')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('这是第二页,显示相同的计数器状态', style: TextStyle(fontSize: 18)),
            SizedBox(height: 20),
            Text(
              '$counter',
              style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
              textAlign: TextAlign.center,
            ),
            SizedBox(height: 20),
            Text('注意: 两页面的计数器状态是同步的', style: TextStyle(color: Colors.grey)),
          ],
        ),
      ),
    );
  }
}

优点

  • 编译时安全,避免运行时错误
  • 不依赖BuildContext,更灵活
  • 更好的性能优化
  • 适合所有规模的项目

缺点

  • 相对较新,生态系统还在成长中
  • 与Provider略有不同,需要重新学习

综合对比

方案 学习曲线 代码量 性能 测试难易度 适用场景 维护性 社区支持
setState 简单 中等 简单 简单组件、局部状态 官方支持
Provider 中等 中等 中等 中小型应用 良好 官方推荐
Bloc 较陡峭 容易 中大型复杂应用 优秀 强大
GetX 简单 极高 中等 中小型应用快速开发 中等 强大
Riverpod 中等 中等 中等 所有规模应用 优秀 成长中

选择建议

  1. 初学者/简单项目:从 setState 开始,逐步学习更复杂的方案
  2. 中小型应用:推荐使用 Provider 或 Riverpod
  3. 大型复杂应用:推荐使用 Bloc 或 Riverpod
  4. 快速开发/原型:可以考虑 GetX
  5. 需要高度可测试性Bloc 是最佳选择
  6. 编译时安全:选择 Riverpod

最佳实践

  1. 根据项目规模选择:不要为简单项目引入复杂方案
  2. 保持一致性:项目中尽量使用统一的状态管理方案
  3. 合理分层:将业务逻辑与UI分离
  4. 适度使用:不是所有状态都需要全局管理
  5. 性能优化:使用选择性重建(如Consumer、Selector等)

总结

Flutter 状态管理没有"唯一最佳"方案,每种方案都有其适用场景。选择时应考虑项目规模、团队经验、性能要求和维护成本等因素。对于大多数应用,Provider 或 Riverpod 是平衡了易用性和功能性的不错选择。随着项目复杂度的增加,可以考虑迁移到 Bloc 等更严格的架构方案。

无论选择哪种方案,理解状态管理的基本原则(如不可变性、单向数据流)比掌握特定库更重要,这些原则可以帮助你构建更健壮、可维护的应用程序。

完整的苹果内购in-purchase实例-采用object-c语言使用苹果的Storyboard开发模式的完整购买实例

作者 卓伊凡
2025年9月12日 00:51

完整的苹果内购in-purchase实例-采用object-c语言使用苹果的Storyboard开发模式的完整购买实例

引言

此前 写了 object-c的实例,但是交付时候发现客户采用的是Storyboard开发模式,因此我们只能单独再写Storyboard开发模式的代码。

项目开源地址:

gitee.com/youyacao/io…

内容如下

首先单独去建立了内购的密钥

密钥的ID为Q9W29CG749 对应的 app 包名 Liu.GameTest

密钥放到本地

对应的内购商品

产品参考名称: LiuAppGame1Item1 产品id: 001Liu

产品参考名称: LiuAppGame1Item2 产品id: 002Liu
产品参考名称: LiuAppGame1Item3 产品id: 003Liu

3个商品对应三种金额,对应不同的价格。

已创建AppDelegate.h文件,现在创建AppDelegate.m文件来实现应用程序的生命周期管理。


创建ViewController.m文件,实现内购的核心逻辑,包括加载商品信息、处理购买请求和交易结果等功能。

创建Main.storyboard文件,实现内购界面的UI设计,包括商品信息展示和购买按钮。

创建的结构目录要符合常规项目需要,最后我会放出结构。

    创建了核心源文件:
  • AppDelegate.h 和 AppDelegate.m :应用程序委托类
  • ViewController.h 和 ViewController.m :包含内购核心逻辑的视图控制器
    实现了内购功能:
  • 支持三个指定的内购商品:001Liu、002Liu和003Liu
  • 实现了商品信息请求、购买流程、交易处理和恢复购买功能
  • 使用了StoreKit.framework框架
    创建了界面文件:
  • Main.storyboard :包含完整的内购UI界面,有商品展示和购买按钮
  • LaunchScreen.storyboard :启动界面
    配置文件:
  • Info.plist :设置了Bundle Identifier为Liu.GameTest
  • project.pbxproj :完整的Xcode项目配置,包含正确的文件路径引用
    项目结构:
  • 遵循Xcode标准目录结构,所有源文件位于 iOSInAppPurchaseDemo 目录下
  • storyboard文件位于 iOSInAppPurchaseDemo/Base.lproj 目录下
  • 创建了 .gitignore 文件忽略不需要版本控制的文件
//
//  ViewController.m
//  iOSInAppPurchaseDemo
//
//  Created by 卓伊凡 on 2025/9/12.
//

#import "ViewController.h"

@interface ViewController () {
    NSArray *_productIdentifiers;
    NSArray *_products;
    SKProductsRequest *_productsRequest;
}

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    // 设置商品ID数组
    _productIdentifiers = @[@"001Liu", @"002Liu", @"003Liu"];

    // 注册为交易观察者
    [[SKPaymentQueue defaultQueue] addTransactionObserver:self];

    // 初始化状态标签
    self.statusLabel.text = @"准备加载商品信息...";

    // 请求商品信息
    [self requestProductsWithIdentifiers:_productIdentifiers];
}

- (void)dealloc {
    // 移除交易观察者
    [[SKPaymentQueue defaultQueue] removeTransactionObserver:self];
}

#pragma mark - 请求商品信息

- (void)requestProductsWithIdentifiers:(NSArray *)productIdentifiers {
    if ([SKPaymentQueue canMakePayments]) {
        NSSet *productIdentifierSet = [NSSet setWithArray:productIdentifiers];
        _productsRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:productIdentifierSet];
        _productsRequest.delegate = self;
        [_productsRequest start];
    } else {
        self.statusLabel.text = @"当前设备不支持应用内购买";
    }
}

#pragma mark - SKProductsRequestDelegate 代理方法

- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response {
    // 存储有效商品
    _products = response.products;

    // 打印无效的商品ID
    if (response.invalidProductIdentifiers.count > 0) {
        NSLog(@"无效的商品ID: %@", response.invalidProductIdentifiers);
    }

    // 更新UI,显示商品信息
    [self updateProductUI];
}

- (void)request:(SKRequest *)request didFailWithError:(NSError *)error {
    NSLog(@"请求商品信息失败: %@", error.localizedDescription);
    self.statusLabel.text = [NSString stringWithFormat:@"加载商品信息失败: %@", error.localizedDescription];
}

- (void)requestDidFinish:(SKRequest *)request {
    // 请求完成
}

#pragma mark - 更新商品UI

- (void)updateProductUI {
    if (_products.count == 0) {
        self.statusLabel.text = @"未找到商品信息,请检查商品ID是否正确";
        return;
    }

    self.statusLabel.text = @"商品信息加载完成,点击购买按钮进行购买";

    // 根据商品ID匹配对应的商品信息
    for (SKProduct *product in _products) {
        NSString *productID = product.productIdentifier;
        NSString *productTitle = product.localizedTitle;
        NSString *productDescription = product.localizedDescription;
        NSString *priceString = [self priceStringForProduct:product];

        NSString *displayText = [NSString stringWithFormat:@"%@\n%@\n价格: %@", productTitle, productDescription, priceString];

        if ([productID isEqualToString:@"001Liu"]) {
            self.product1Label.text = displayText;
        } else if ([productID isEqualToString:@"002Liu"]) {
            self.product2Label.text = displayText;
        } else if ([productID isEqualToString:@"003Liu"]) {
            self.product3Label.text = displayText;
        }
    }
}

#pragma mark - 格式化价格

- (NSString *)priceStringForProduct:(SKProduct *)product {
    NSNumberFormatter *priceFormatter = [[NSNumberFormatter alloc] init];
    [priceFormatter setFormatterBehavior:NSNumberFormatterBehavior10_4];
    [priceFormatter setNumberStyle:NSNumberFormatterCurrencyStyle];
    [priceFormatter setLocale:product.priceLocale];

    return [priceFormatter stringFromNumber:product.price];
}

#pragma mark - 购买按钮点击事件

- (IBAction)purchaseProduct1:(id)sender {
    [self purchaseProductWithIdentifier:@"001Liu"];
}

- (IBAction)purchaseProduct2:(id)sender {
    [self purchaseProductWithIdentifier:@"002Liu"];
}

- (IBAction)purchaseProduct3:(id)sender {
    [self purchaseProductWithIdentifier:@"003Liu"];
}

#pragma mark - 发起购买请求

- (void)purchaseProductWithIdentifier:(NSString *)productIdentifier {
    if (![SKPaymentQueue canMakePayments]) {
        UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"购买失败" message:@"当前设备不支持应用内购买" preferredStyle:UIAlertControllerStyleAlert];
        [alert addAction:[UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDefault handler:nil]];
        [self presentViewController:alert animated:YES completion:nil];
        return;
    }

    // 查找对应的商品
    SKProduct *productToPurchase = nil;
    for (SKProduct *product in _products) {
        if ([product.productIdentifier isEqualToString:productIdentifier]) {
            productToPurchase = product;
            break;
        }
    }

    if (productToPurchase) {
        self.statusLabel.text = [NSString stringWithFormat:@"正在发起购买请求: %@", productToPurchase.localizedTitle];

        // 创建支付请求
        SKPayment *payment = [SKPayment paymentWithProduct:productToPurchase];
        // 将支付请求添加到支付队列
        [[SKPaymentQueue defaultQueue] addPayment:payment];
    } else {
        self.statusLabel.text = @"未找到该商品信息";
    }
}

#pragma mark - SKPaymentTransactionObserver 代理方法

- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions {
    for (SKPaymentTransaction *transaction in transactions) {
        switch (transaction.transactionState) {
            case SKPaymentTransactionStatePurchasing:
                // 购买中
                self.statusLabel.text = @"购买处理中...";
                break;
            case SKPaymentTransactionStatePurchased:
                // 购买成功
                [self completeTransaction:transaction];
                break;
            case SKPaymentTransactionStateFailed:
                // 购买失败
                [self failedTransaction:transaction];
                break;
            case SKPaymentTransactionStateRestored:
                // 恢复购买
                [self restoreTransaction:transaction];
                break;
            case SKPaymentTransactionStateDeferred:
                // 购买延迟(儿童模式需要家长批准)
                self.statusLabel.text = @"购买等待批准...";
                break;
            default:
                break;
        }
    }
}

#pragma mark - 处理交易结果

- (void)completeTransaction:(SKPaymentTransaction *)transaction {
    // 获取购买的商品ID
    NSString *productID = transaction.payment.productIdentifier;

    // 这里可以添加解锁内容、保存购买记录等逻辑

    // 显示购买成功提示
    UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"购买成功" message:[NSString stringWithFormat:@"您已成功购买: %@", productID] preferredStyle:UIAlertControllerStyleAlert];
    [alert addAction:[UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDefault handler:nil]];
    [self presentViewController:alert animated:YES completion:nil];

    self.statusLabel.text = [NSString stringWithFormat:@"购买成功: %@", productID];

    // 完成交易,从队列中移除
    [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
}

- (void)failedTransaction:(SKPaymentTransaction *)transaction {
    // 处理购买失败
    if (transaction.error.code != SKErrorPaymentCancelled) {
        NSLog(@"购买失败: %@", transaction.error.localizedDescription);

        UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"购买失败" message:transaction.error.localizedDescription preferredStyle:UIAlertControllerStyleAlert];
        [alert addAction:[UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDefault handler:nil]];
        [self presentViewController:alert animated:YES completion:nil];

        self.statusLabel.text = [NSString stringWithFormat:@"购买失败: %@", transaction.error.localizedDescription];
    } else {
        // 用户取消购买
        self.statusLabel.text = @"购买已取消";
    }

    // 完成交易,从队列中移除
    [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
}

- (void)restoreTransaction:(SKPaymentTransaction *)transaction {
    // 获取恢复的商品ID
    NSString *productID = transaction.originalTransaction.payment.productIdentifier;

    // 这里可以添加解锁内容等逻辑

    // 显示恢复购买成功提示
    UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"恢复购买成功" message:[NSString stringWithFormat:@"您已成功恢复购买: %@", productID] preferredStyle:UIAlertControllerStyleAlert];
    [alert addAction:[UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDefault handler:nil]];
    [self presentViewController:alert animated:YES completion:nil];

    self.statusLabel.text = [NSString stringWithFormat:@"恢复购买成功: %@", productID];

    // 完成交易,从队列中移除
    [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
}

#pragma mark - 恢复购买

- (IBAction)restorePurchases:(id)sender {
    if ([SKPaymentQueue canMakePayments]) {
        self.statusLabel.text = @"正在恢复购买...";
        [[SKPaymentQueue defaultQueue] restoreCompletedTransactions];
    } else {
        self.statusLabel.text = @"当前设备不支持应用内购买";
    }
}

@end

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21505" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
    <device id="retina4_7" orientation="portrait" appearance="light"/>
    <dependencies>
        <deployment identifier="iOS"/>
        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21504"/>
        <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
    </dependencies>
    <scenes>
        <!--View Controller-->
        <scene sceneID="tne-QT-ifu">
            <objects>
                <viewController id="BYZ-38-t0r" customClass="ViewController" customModule="iOSInAppPurchaseDemo" customModuleProvider="target" sceneMemberID="viewController">
                    <layoutGuides>
                        <viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
                        <viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
                    </layoutGuides>
                    <view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
                        <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
                        <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
                        <subviews>
                            <!-- 标题标签 -->
                            <label userLabel="标题" text="应用内购买演示" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="e1v-1X-jkq">
                                <rect key="frame" x="37.5" y="44" width="300" height="40"/>
                                <fontDescription key="fontDescription" type="system" pointSize="24" weight="medium"/>
                                <color key="textColor" red="0.1294117647" green="0.1294117647" blue="0.1294117647" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                <nil key="highlightedColor"/>
                            </label>

                            <!-- 状态标签 -->
                            <label userLabel="状态" text="加载中..." textAlignment="center" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="e2r-2Y-lkq">
                                <rect key="frame" x="37.5" y="94" width="300" height="40"/>
                                <fontDescription key="fontDescription" type="system" pointSize="14"/>
                                <color key="textColor" red="0.5568627451" green="0.5568627451" blue="0.5764705882" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                <nil key="highlightedColor"/>
                            </label>

                            <!-- 商品1 -->
                            <view userLabel="商品1容器" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="d1v-3X-mkq">
                                <rect key="frame" x="37.5" y="154" width="300" height="100"/>
                                <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
                                <subviews>
                                    <label userLabel="商品1信息" text="商品信息加载中..." textAlignment="natural" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="f1v-4X-nkq">
                                        <rect key="frame" x="0.0" y="0.0" width="220" height="100"/>
                                        <fontDescription key="fontDescription" type="system" pointSize="14"/>
                                        <color key="textColor" red="0.1294117647" green="0.1294117647" blue="0.1294117647" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                        <nil key="highlightedColor"/>
                                    </label>
                                    <button userLabel="购买按钮1" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="g1v-5X-okq">
                                        <rect key="frame" x="230" y="30" width="60" height="40"/>
                                        <autoresizingMask key="autoresizingMask"/>
                                        <fontDescription key="fontDescription" type="system" pointSize="14" weight="medium"/>
                                        <state key="normal" title="购买"/>
                                        <color key="backgroundColor" red="0.0" green="0.4784313725" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                        <color key="titleColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                    </button>
                                </subviews>
                                <color key="backgroundColor" red="0.9411764706" green="0.9411764706" blue="0.9411764706" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                <constraints>
                                    <constraint firstItem="f1v-4X-nkq" firstAttribute="top" secondItem="d1v-3X-mkq" secondAttribute="top" constant="0.0" id="a1c-6X-pkq"/>
                                    <constraint firstAttribute="bottom" secondItem="f1v-4X-nkq" secondAttribute="bottom" constant="0.0" id="b1c-7X-qkq"/>
                                    <constraint firstItem="f1v-4X-nkq" firstAttribute="leading" secondItem="d1v-3X-mkq" secondAttribute="leading" constant="10" id="c1c-8X-rkq"/>
                                    <constraint firstItem="g1v-5X-okq" firstAttribute="trailing" secondItem="d1v-3X-mkq" secondAttribute="trailing" constant="-10" id="d1c-9X-skq"/>
                                    <constraint firstAttribute="bottom" secondItem="g1v-5X-okq" secondAttribute="bottom" constant="30" id="e1c-0Y-tkq"/>
                                    <constraint firstItem="g1v-5X-okq" firstAttribute="top" secondItem="d1v-3X-mkq" secondAttribute="top" constant="30" id="f1c-1Y-ukq"/>
                                    <constraint firstAttribute="trailing" secondItem="f1v-4X-nkq" secondAttribute="trailing" constant="70" id="g1c-2Y-vkq"/>
                                    <constraint firstItem="g1v-5X-okq" firstAttribute="leading" secondItem="f1v-4X-nkq" secondAttribute="trailing" constant="10" id="h1c-3Y-wkq"/>
                                </constraints>
                                <userLabel value="商品1容器"/>
                                <cornerRadius key="cornerRadius" value="8"/>
                            </view>

                            <!-- 商品2 -->
                            <view userLabel="商品2容器" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="h1v-4X-xkq">
                                <rect key="frame" x="37.5" y="264" width="300" height="100"/>
                                <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
                                <subviews>
                                    <label userLabel="商品2信息" text="商品信息加载中..." textAlignment="natural" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="i1v-5X-ykq">
                                        <rect key="frame" x="0.0" y="0.0" width="220" height="100"/>
                                        <fontDescription key="fontDescription" type="system" pointSize="14"/>
                                        <color key="textColor" red="0.1294117647" green="0.1294117647" blue="0.1294117647" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                        <nil key="highlightedColor"/>
                                    </label>
                                    <button userLabel="购买按钮2" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="j1v-6X-zkq">
                                        <rect key="frame" x="230" y="30" width="60" height="40"/>
                                        <autoresizingMask key="autoresizingMask"/>
                                        <fontDescription key="fontDescription" type="system" pointSize="14" weight="medium"/>
                                        <state key="normal" title="购买"/>
                                        <color key="backgroundColor" red="0.0" green="0.4784313725" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                        <color key="titleColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                    </button>
                                </subviews>
                                <color key="backgroundColor" red="0.9411764706" green="0.9411764706" blue="0.9411764706" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                <constraints>
                                    <constraint firstItem="i1v-5X-ykq" firstAttribute="top" secondItem="h1v-4X-xkq" secondAttribute="top" constant="0.0" id="a2c-7X-0kq"/>
                                    <constraint firstAttribute="bottom" secondItem="i1v-5X-ykq" secondAttribute="bottom" constant="0.0" id="b2c-8X-1kq"/>
                                    <constraint firstItem="i1v-5X-ykq" firstAttribute="leading" secondItem="h1v-4X-xkq" secondAttribute="leading" constant="10" id="c2c-9X-2kq"/>
                                    <constraint firstItem="j1v-6X-zkq" firstAttribute="trailing" secondItem="h1v-4X-xkq" secondAttribute="trailing" constant="-10" id="d2c-0Y-3kq"/>
                                    <constraint firstAttribute="bottom" secondItem="j1v-6X-zkq" secondAttribute="bottom" constant="30" id="e2c-1Y-4kq"/>
                                    <constraint firstItem="j1v-6X-zkq" firstAttribute="top" secondItem="h1v-4X-xkq" secondAttribute="top" constant="30" id="f2c-2Y-5kq"/>
                                    <constraint firstAttribute="trailing" secondItem="i1v-5X-ykq" secondAttribute="trailing" constant="70" id="g2c-3Y-6kq"/>
                                    <constraint firstItem="j1v-6X-zkq" firstAttribute="leading" secondItem="i1v-5X-ykq" secondAttribute="trailing" constant="10" id="h2c-4Y-7kq"/>
                                </constraints>
                                <userLabel value="商品2容器"/>
                                <cornerRadius key="cornerRadius" value="8"/>
                            </view>

                            <!-- 商品3 -->
                            <view userLabel="商品3容器" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="k1v-7X-8kq">
                                <rect key="frame" x="37.5" y="374" width="300" height="100"/>
                                <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
                                <subviews>
                                    <label userLabel="商品3信息" text="商品信息加载中..." textAlignment="natural" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="l1v-8X-9kq">
                                        <rect key="frame" x="0.0" y="0.0" width="220" height="100"/>
                                        <fontDescription key="fontDescription" type="system" pointSize="14"/>
                                        <color key="textColor" red="0.1294117647" green="0.1294117647" blue="0.1294117647" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                        <nil key="highlightedColor"/>
                                    </label>
                                    <button userLabel="购买按钮3" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="m1v-9X-akq">
                                        <rect key="frame" x="230" y="30" width="60" height="40"/>
                                        <autoresizingMask key="autoresizingMask"/>
                                        <fontDescription key="fontDescription" type="system" pointSize="14" weight="medium"/>
                                        <state key="normal" title="购买"/>
                                        <color key="backgroundColor" red="0.0" green="0.4784313725" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                        <color key="titleColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                    </button>
                                </subviews>
                                <color key="backgroundColor" red="0.9411764706" green="0.9411764706" blue="0.9411764706" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                <constraints>
                                    <constraint firstItem="l1v-8X-9kq" firstAttribute="top" secondItem="k1v-7X-8kq" secondAttribute="top" constant="0.0" id="a3c-0Y-bkq"/>
                                    <constraint firstAttribute="bottom" secondItem="l1v-8X-9kq" secondAttribute="bottom" constant="0.0" id="b3c-1Y-ckq"/>
                                    <constraint firstItem="l1v-8X-9kq" firstAttribute="leading" secondItem="k1v-7X-8kq" secondAttribute="leading" constant="10" id="c3c-2Y-dkq"/>
                                    <constraint firstItem="m1v-9X-akq" firstAttribute="trailing" secondItem="k1v-7X-8kq" secondAttribute="trailing" constant="-10" id="d3c-3Y-ekq"/>
                                    <constraint firstAttribute="bottom" secondItem="m1v-9X-akq" secondAttribute="bottom" constant="30" id="e3c-4Y-fkq"/>
                                    <constraint firstItem="m1v-9X-akq" firstAttribute="top" secondItem="k1v-7X-8kq" secondAttribute="top" constant="30" id="f3c-5Y-gkq"/>
                                    <constraint firstAttribute="trailing" secondItem="l1v-8X-9kq" secondAttribute="trailing" constant="70" id="g3c-6Y-hkq"/>
                                    <constraint firstItem="m1v-9X-akq" firstAttribute="leading" secondItem="l1v-8X-9kq" secondAttribute="trailing" constant="10" id="h3c-7Y-ikq"/>
                                </constraints>
                                <userLabel value="商品3容器"/>
                                <cornerRadius key="cornerRadius" value="8"/>
                            </view>

                            <!-- 恢复购买按钮 -->
                            <button userLabel="恢复购买" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="n1v-8X-jkq">
                                <rect key="frame" x="37.5" y="494" width="300" height="40"/>
                                <autoresizingMask key="autoresizingMask"/>
                                <fontDescription key="fontDescription" type="system" pointSize="16" weight="medium"/>
                                <state key="normal" title="恢复购买"/>
                                <color key="backgroundColor" red="0.6862745098" green="0.6862745098" blue="0.6862745098" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                <color key="titleColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                            </button>
                        </subviews>
                        <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                        <constraints>
                            <!-- 标题约束 -->
                            <constraint firstItem="e1v-1X-jkq" firstAttribute="leading" secondItem="8bC-Xf-vdC" secondAttribute="leading" constant="37.5" id="a4c-9X-kkq"/>
                            <constraint firstItem="e1v-1X-jkq" firstAttribute="top" secondItem="y3c-jy-aDJ" secondAttribute="bottom" constant="0.0" id="b4c-0Y-lkq"/>
                            <constraint firstAttribute="trailing" secondItem="e1v-1X-jkq" secondAttribute="trailing" constant="37.5" id="c4c-1Y-mkq"/>

                            <!-- 状态约束 -->
                            <constraint firstItem="e2r-2Y-lkq" firstAttribute="leading" secondItem="8bC-Xf-vdC" secondAttribute="leading" constant="37.5" id="d4c-2Y-nkq"/>
                            <constraint firstItem="e2r-2Y-lkq" firstAttribute="top" secondItem="e1v-1X-jkq" secondAttribute="bottom" constant="10" id="e4c-3Y-okq"/>
                            <constraint firstAttribute="trailing" secondItem="e2r-2Y-lkq" secondAttribute="trailing" constant="37.5" id="f4c-4Y-pkq"/>

                            <!-- 商品1约束 -->
                            <constraint firstItem="d1v-3X-mkq" firstAttribute="leading" secondItem="8bC-Xf-vdC" secondAttribute="leading" constant="37.5" id="g4c-5Y-qkq"/>
                            <constraint firstItem="d1v-3X-mkq" firstAttribute="top" secondItem="e2r-2Y-lkq" secondAttribute="bottom" constant="20" id="h4c-6Y-rkq"/>
                            <constraint firstAttribute="trailing" secondItem="d1v-3X-mkq" secondAttribute="trailing" constant="37.5" id="i4c-7Y-skq"/>
                            <constraint firstAttribute="width" secondItem="d1v-3X-mkq" secondAttribute="width" multiplier="1" constant="0.0" id="j4c-8Y-tkq"/>
                            <constraint firstItem="d1v-3X-mkq" firstAttribute="height" constant="100" id="k4c-9Y-ukq"/>

                            <!-- 商品2约束 -->
                            <constraint firstItem="h1v-4X-xkq" firstAttribute="leading" secondItem="8bC-Xf-vdC" secondAttribute="leading" constant="37.5" id="l4c-0Z-vkq"/>
                            <constraint firstItem="h1v-4X-xkq" firstAttribute="top" secondItem="d1v-3X-mkq" secondAttribute="bottom" constant="10" id="m4c-1Z-wkq"/>
                            <constraint firstAttribute="trailing" secondItem="h1v-4X-xkq" secondAttribute="trailing" constant="37.5" id="n4c-2Z-xkq"/>
                            <constraint firstItem="h1v-4X-xkq" firstAttribute="height" constant="100" id="o4c-3Z-ykq"/>

                            <!-- 商品3约束 -->
                            <constraint firstItem="k1v-7X-8kq" firstAttribute="leading" secondItem="8bC-Xf-vdC" secondAttribute="leading" constant="37.5" id="p4c-4Z-zkq"/>
                            <constraint firstItem="k1v-7X-8kq" firstAttribute="top" secondItem="h1v-4X-xkq" secondAttribute="bottom" constant="10" id="q4c-5Z-0kq"/>
                            <constraint firstAttribute="trailing" secondItem="k1v-7X-8kq" secondAttribute="trailing" constant="37.5" id="r4c-6Z-1kq"/>
                            <constraint firstItem="k1v-7X-8kq" firstAttribute="height" constant="100" id="s4c-7Z-2kq"/>

                            <!-- 恢复购买按钮约束 -->
                            <constraint firstItem="n1v-8X-jkq" firstAttribute="leading" secondItem="8bC-Xf-vdC" secondAttribute="leading" constant="37.5" id="t4c-8Z-3kq"/>
                            <constraint firstItem="n1v-8X-jkq" firstAttribute="top" secondItem="k1v-7X-8kq" secondAttribute="bottom" constant="20" id="u4c-9Z-4kq"/>
                            <constraint firstAttribute="trailing" secondItem="n1v-8X-jkq" secondAttribute="trailing" constant="37.5" id="v4c-0a-5kq"/>
                            <constraint firstItem="n1v-8X-jkq" firstAttribute="height" constant="40" id="w4c-1a-6kq"/>
                        </constraints>
                    </view>
                    <connections>
                        <outlet property="product1Label" destination="f1v-4X-nkq" id="con-0a-7kq"/>
                        <outlet property="product2Label" destination="i1v-5X-ykq" id="con-1a-8kq"/>
                        <outlet property="product3Label" destination="l1v-8X-9kq" id="con-2a-9kq"/>
                        <outlet property="product1Button" destination="g1v-5X-okq" id="con-3a-akq"/>
                        <outlet property="product2Button" destination="j1v-6X-zkq" id="con-4a-bkq"/>
                        <outlet property="product3Button" destination="m1v-9X-akq" id="con-5a-ckq"/>
                        <outlet property="statusLabel" destination="e2r-2Y-lkq" id="con-6a-dkq"/>
                        <action selector="purchaseProduct1:" destination="BYZ-38-t0r" eventType="touchUpInside" id="con-7a-ekq">
                            <sender slot="source" id="g1v-5X-okq"/>
                        </action>
                        <action selector="purchaseProduct2:" destination="BYZ-38-t0r" eventType="touchUpInside" id="con-8a-fkq">
                            <sender slot="source" id="j1v-6X-zkq"/>
                        </action>
                        <action selector="purchaseProduct3:" destination="BYZ-38-t0r" eventType="touchUpInside" id="con-9a-gkq">
                            <sender slot="source" id="m1v-9X-akq"/>
                        </action>
                        <action selector="restorePurchases:" destination="BYZ-38-t0r" eventType="touchUpInside" id="con-0b-hkq">
                            <sender slot="source" id="n1v-8X-jkq"/>
                        </action>
                    </connections>
                </viewController>
                <placeholder placeholderIdentifier="IBFilesOwner" id="dkx-z0-nzr" sceneMemberID="filesOwner"/>
                <placeholder placeholderIdentifier="IBFirstResponder" id="26j-XQyn0c" userLabel="First Responder" sceneMemberID="firstResponder"/>
            </objects>
            <point key="canvasLocation" x="137.5" y="102.5"/>
        </scene>
    </scenes>
</document>

建立info.list创建Info.plist文件,配置应用程序的基本信息,包括应用名称、版本、Bundle Identifier等。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleDevelopmentRegion</key>
    <string>$(DEVELOPMENT_LANGUAGE)</string>
    <key>CFBundleDisplayName</key>
    <string>iOSInAppPurchaseDemo</string>
    <key>CFBundleExecutable</key>
    <string>$(EXECUTABLE_NAME)</string>
    <key>CFBundleIdentifier</key>
    <string>Liu.GameTest</string>
    <key>CFBundleInfoDictionaryVersion</key>
    <string>6.0</string>
    <key>CFBundleName</key>
    <string>$(PRODUCT_NAME)</string>
    <key>CFBundlePackageType</key>
    <string>APPL</string>
    <key>CFBundleShortVersionString</key>
    <string>1.0</string>
    <key>CFBundleVersion</key>
    <string>1</string>
    <key>LSRequiresIPhoneOS</key>
    <true/>
    <key>UILaunchStoryboardName</key>
    <string>LaunchScreen</string>
    <key>UIMainStoryboardFile</key>
    <string>Main</string>
    <key>UIRequiredDeviceCapabilities</key>
    <array>
        <string>armv7</string>
    </array>
    <key>UISupportedInterfaceOrientations</key>
    <array>
        <string>UIInterfaceOrientationPortrait</string>
        <string>UIInterfaceOrientationLandscapeLeft</string>
        <string>UIInterfaceOrientationLandscapeRight</string>
    </array>
    <key>UISupportedInterfaceOrientations~ipad</key>
    <array>
        <string>UIInterfaceOrientationPortrait</string>
        <string>UIInterfaceOrientationPortraitUpsideDown</string>
        <string>UIInterfaceOrientationLandscapeLeft</string>
        <string>UIInterfaceOrientationLandscapeRight</string>
    </array>
    <key>NSAppTransportSecurity</key>
    <dict>
        <key>NSAllowsArbitraryLoads</key>
        <true/>
    </dict>
</dict>
</plist>

创建LaunchScreen.storyboard文件,作为应用程序的启动界面

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21505" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" launchScreen="YES">
    <device id="retina4_7" orientation="portrait" appearance="light"/>
    <dependencies>
        <deployment identifier="iOS"/>
        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21504"/>
        <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
    </dependencies>
    <scenes>
        <!--View Controller-->
        <scene sceneID="tne-QT-ifu">
            <objects>
                <viewController id="BYZ-38-t0r" sceneMemberID="viewController">
                    <layoutGuides>
                        <viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
                        <viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
                    </layoutGuides>
                    <view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
                        <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
                        <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
                        <subviews>
                            <label userLabel="启动标题" text="iOS内购演示" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="e1v-1X-jkq">
                                <rect key="frame" x="75" y="276.5" width="225" height="40"/>
                                <fontDescription key="fontDescription" type="system" pointSize="30" weight="medium"/>
                                <color key="textColor" red="0.1294117647" green="0.1294117647" blue="0.1294117647" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                <nil key="highlightedColor"/>
                            </label>
                            <label userLabel="启动副标题" text="Loading..." textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="e2v-2X-lkq">
                                <rect key="frame" x="75" y="326.5" width="225" height="20"/>
                                <fontDescription key="fontDescription" type="system" pointSize="16"/>
                                <color key="textColor" red="0.5568627451" green="0.5568627451" blue="0.5764705882" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                <nil key="highlightedColor"/>
                            </label>
                        </subviews>
                        <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                        <constraints>
                            <constraint firstItem="e1v-1X-jkq" firstAttribute="leading" secondItem="8bC-Xf-vdC" secondAttribute="leading" constant="75" id="a1c-3X-mkq"/>
                            <constraint firstItem="e1v-1X-jkq" firstAttribute="centerY" secondItem="8bC-Xf-vdC" secondAttribute="centerY" constant="-25" id="b1c-4X-nkq"/>
                            <constraint firstAttribute="trailing" secondItem="e1v-1X-jkq" secondAttribute="trailing" constant="75" id="c1c-5X-okq"/>
                            <constraint firstItem="e2v-2X-lkq" firstAttribute="leading" secondItem="8bC-Xf-vdC" secondAttribute="leading" constant="75" id="d1c-6X-pkq"/>
                            <constraint firstItem="e2v-2X-lkq" firstAttribute="top" secondItem="e1v-1X-jkq" secondAttribute="bottom" constant="10" id="e1c-7X-qkq"/>
                            <constraint firstAttribute="trailing" secondItem="e2v-2X-lkq" secondAttribute="trailing" constant="75" id="f1c-8X-rkq"/>
                        </constraints>
                    </view>
                </viewController>
                <placeholder placeholderIdentifier="IBFilesOwner" id="dkx-z0-nzr" sceneMemberID="filesOwner"/>
                <placeholder placeholderIdentifier="IBFirstResponder" id="26j-XQyn0c" userLabel="First Responder" sceneMemberID="firstResponder"/>
            </objects>
            <point key="canvasLocation" x="137.5" y="102.5"/>
        </scene>
    </scenes>
</document>

aunchScreen.storyboard已创建完成。现在需要创建Xcode项目配置文件project.pbxproj,这是项目的核心配置文件,定义了项目结构和构建设置。

// !$*UTF8*$!
{
    archiveVersion = 1;
    classes = {
    };
    objectVersion = 46;
    objects = {
        /* Begin PBXBuildFile section */
        19786D9D1E8E4F5A00D94E8C /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 19786D9C1E8E4F5A00D94E8C /* AppDelegate.m */; };
        19786D9F1E8E4F5A00D94E8C /* ViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 19786D9E1E8E4F5A00D94E8C /* ViewController.m */; };
        19786DA31E8E4F5A00D94E8C /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 19786DA21E8E4F5A00D94E8C /* Main.storyboard */; };
        19786DA51E8E4F5A00D94E8C /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 19786DA41E8E4F5A00D94E8C /* LaunchScreen.storyboard */; };
        19786DA71E8E4F5A00D94E8C /* Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 19786DA61E8E4F5A00D94E8C /* Info.plist */; };
        /* End PBXBuildFile section */

        /* Begin PBXFileReference section */
        19786D9A1E8E4F5A00D94E8C /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = iOSInAppPurchaseDemo/AppDelegate.h; sourceTree = "<group>"; };
        19786D9C1E8E4F5A00D94E8C /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = iOSInAppPurchaseDemo/AppDelegate.m; sourceTree = "<group>"; };
        19786D9D1E8E4F5A00D94E8C /* ViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = iOSInAppPurchaseDemo/ViewController.h; sourceTree = "<group>"; };
        19786D9E1E8E4F5A00D94E8C /* ViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = iOSInAppPurchaseDemo/ViewController.m; sourceTree = "<group>"; };
        19786DA21E8E4F5A00D94E8C /* Main.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = iOSInAppPurchaseDemo/Base.lproj/Main.storyboard; sourceTree = "<group>"; };
        19786DA41E8E4F5A00D94E8C /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = iOSInAppPurchaseDemo/Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
        19786DA61E8E4F5A00D94E8C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = iOSInAppPurchaseDemo/Info.plist; sourceTree = "<group>"; };
        19786DA91E8E4F5A00D94E8C /* iOSInAppPurchaseDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iOSInAppPurchaseDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
        19786DAA1E8E4F5A00D94E8C /* SubscriptionKey_Q9W29CG749.p8 */ = {isa = PBXFileReference; lastKnownFileType = text; path = SubscriptionKey_Q9W29CG749.p8; sourceTree = "<group>"; };
        /* End PBXFileReference section */

        /* Begin PBXGroup section */
        19786D931E8E4F5A00D94E8C = {isa = PBXGroup; children = (
            19786D9A1E8E4F5A00D94E8C /* AppDelegate.h */,
            19786D9C1E8E4F5A00D94E8C /* AppDelegate.m */,
            19786D9D1E8E4F5A00D94E8C /* ViewController.h */,
            19786D9E1E8E4F5A00D94E8C /* ViewController.m */,
            19786DA01E8E4F5A00D94E8C /* Base.lproj */,
            19786DA61E8E4F5A00D94E8C /* Info.plist */,
        ); path = iOSInAppPurchaseDemo; sourceTree = "<group>"; };
        19786DA01E8E4F5A00D94E8C /* Base.lproj */ = {isa = PBXGroup; children = (
            19786DA21E8E4F5A00D94E8C /* Main.storyboard */,
            19786DA41E8E4F5A00D94E8C /* LaunchScreen.storyboard */,
        ); path = Base.lproj; sourceTree = "<group>"; };
        19786D921E8E4F5A00D94E8C = {isa = PBXGroup; children = (
            19786D931E8E4F5A00D94E8C /* iOSInAppPurchaseDemo */,
            19786DA91E8E4F5A00D94E8C /* Products */,
        ); sourceTree = "<group>"; };
        19786DA91E8E4F5A00D94E8C = {isa = PBXGroup; children = (
            19786DA91E8E4F5A00D94E8C /* iOSInAppPurchaseDemo.app */,
        ); name = Products; sourceTree = "<group>"; };
        /* End PBXGroup section */

        /* Begin PBXNativeTarget section */
        19786D991E8E4F5A00D94E8C = {isa = PBXNativeTarget; buildConfigurationList = 19786DAE1E8E4F5A00D94E8C /* Build configuration list for PBXNativeTarget "iOSInAppPurchaseDemo" */; buildPhases = (
            19786D951E8E4F5A00D94E8C /* Sources */,
            19786D961E8E4F5A00D94E8C /* Resources */,
            19786D971E8E4F5A00D94E8C /* Frameworks */,
        ); buildRules = (
        ); dependencies = (
        ); name = iOSInAppPurchaseDemo; productName = iOSInAppPurchaseDemo; productReference = 19786DA91E8E4F5A00D94E8C /* iOSInAppPurchaseDemo.app */; productType = "com.apple.product-type.application"; };
        /* End PBXNativeTarget section */

        /* Begin PBXProject section */
        19786D911E8E4F5A00D94E8C = {isa = PBXProject; attributes = {
            LastSwiftUpdateCheck = 0900;
            LastUpgradeCheck = 0900;
            ORGANIZATIONNAME = "Your Organization";
            targetAttributes = {
                19786D991E8E4F5A00D94E8C = {
                    CreatedOnToolsVersion = 9.0;
                    LastSwiftMigration = 0900;
                };
            };
        };
            buildConfigurationList = 19786D901E8E4F5A00D94E8C /* Build configuration list for PBXProject "iOSInAppPurchaseDemo" */;
            compatibilityVersion = "Xcode 3.2";
            developmentRegion = "zh_CN";
            hasScannedForEncodings = 0;
            mainGroup = 19786D921E8E4F5A00D94E8C;
            productRefGroup = 19786DA91E8E4F5A00D94E8C /* Products */;
            projectDirPath = "";
            projectRoot = "";
            targets = (
                19786D991E8E4F5A00D94E8C /* iOSInAppPurchaseDemo */,
            );
        };
        /* End PBXProject section */

        /* Begin PBXResourcesBuildPhase section */
        19786D961E8E4F5A00D94E8C /* Resources */ = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647;
            files = (
                19786DA31E8E4F5A00D94E8C /* Main.storyboard in Resources */,
                19786DA51E8E4F5A00D94E8C /* LaunchScreen.storyboard in Resources */,
                19786DA71E8E4F5A00D94E8C /* Info.plist in Resources */,
            );
            runOnlyForDeploymentPostprocessing = 0;
        };
        /* End PBXResourcesBuildPhase section */

        /* Begin PBXSourcesBuildPhase section */
        19786D951E8E4F5A00D94E8C /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647;
            files = (
                19786D9D1E8E4F5A00D94E8C /* AppDelegate.m in Sources */,
                19786D9F1E8E4F5A00D94E8C /* ViewController.m in Sources */,
            );
            runOnlyForDeploymentPostprocessing = 0;
        };
        /* End PBXSourcesBuildPhase section */

        /* Begin PBXFrameworksBuildPhase section */
        19786D971E8E4F5A00D94E8C /* Frameworks */ = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647;
            files = (
                19786DB01E8E4F5A00D94E8C /* StoreKit.framework in Frameworks */,
            );
            runOnlyForDeploymentPostprocessing = 0;
        };
        /* End PBXFrameworksBuildPhase section */

        /* Begin PBXBuildFile for Frameworks */
        19786DB01E8E4F5A00D94E8C /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 19786DB11E8E4F5A00D94E8C /* StoreKit.framework */; };
        /* End PBXBuildFile for Frameworks */

        /* Begin PBXFileReference for Frameworks */
        19786DB11E8E4F5A00D94E8C /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
        /* End PBXFileReference for Frameworks */

        /* Begin PBXBuildConfiguration section */
        19786D9B1E8E4F5A00D94E8C /* Debug */ = {isa = PBXBuildConfiguration; buildSettings = {
            ALWAYS_SEARCH_USER_PATHS = NO;
            CLANG_ANALYZER_NONNULL = YES;
            CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
            CLANG_CXX_LIBRARY = "libc++";
            CLANG_ENABLE_MODULES = YES;
            CLANG_ENABLE_OBJC_ARC = YES;
            CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
            CLANG_WARN_BOOL_CONVERSION = YES;
            CLANG_WARN_COMMA = YES;
            CLANG_WARN_CONSTANT_CONVERSION = YES;
            CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
            CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES;
            CLANG_WARN_EMPTY_BODY = YES;
            CLANG_WARN_ENUM_CONVERSION = YES;
            CLANG_WARN_INFINITE_RECURSION = YES;
            CLANG_WARN_INT_CONVERSION = YES;
            CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
            CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
            CLANG_WARN_OBJC_ROOT_CLASS = YES;
            CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
            CLANG_WARN_STRICT_PROTOTYPES = YES;
            CLANG_WARN_SUSPICIOUS_MOVE = YES;
            CLANG_WARN_UNREACHABLE_CODE = YES;
            CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
            COPY_PHASE_STRIP = NO;
            DEBUG_INFORMATION_FORMAT = dwarf;
            ENABLE_STRICT_OBJC_MSGSEND = YES;
            ENABLE_TESTABILITY = YES;
            GCC_C_LANGUAGE_STANDARD = gnu99;
            GCC_DYNAMIC_NO_PIC = NO;
            GCC_NO_COMMON_BLOCKS = YES;
            GCC_OPTIMIZATION_LEVEL = 0;
            GCC_PRECOMPILE_PREFIX_HEADER = NO;
            GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
            GCC_WARN_ABOUT_RETURN_TYPE = YES;
            GCC_WARN_UNDECLARED_SELECTOR = YES;
            GCC_WARN_UNINITIALIZED_AUTOS = YES;
            GCC_WARN_UNUSED_FUNCTION = YES;
            GCC_WARN_UNUSED_VARIABLE = YES;
            IPHONEOS_DEPLOYMENT_TARGET = 12.0;
            MTL_ENABLE_DEBUG_INFO = YES;
            MTL_FAST_MATH = YES;
            ONLY_ACTIVE_ARCH = YES;
            SDKROOT = iphoneos;
            TARGETED_DEVICE_FAMILY = "1,2";
        };
            name = Debug;
        };
        19786D9C1E8E4F5A00D94E8C /* Release */ = {isa = PBXBuildConfiguration; buildSettings = {
            ALWAYS_SEARCH_USER_PATHS = NO;
            CLANG_ANALYZER_NONNULL = YES;
            CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
            CLANG_CXX_LIBRARY = "libc++";
            CLANG_ENABLE_MODULES = YES;
            CLANG_ENABLE_OBJC_ARC = YES;
            CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
            CLANG_WARN_BOOL_CONVERSION = YES;
            CLANG_WARN_COMMA = YES;
            CLANG_WARN_CONSTANT_CONVERSION = YES;
            CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
            CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES;
            CLANG_WARN_EMPTY_BODY = YES;
            CLANG_WARN_ENUM_CONVERSION = YES;
            CLANG_WARN_INFINITE_RECURSION = YES;
            CLANG_WARN_INT_CONVERSION = YES;
            CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
            CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
            CLANG_WARN_OBJC_ROOT_CLASS = YES;
            CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
            CLANG_WARN_STRICT_PROTOTYPES = YES;
            CLANG_WARN_SUSPICIOUS_MOVE = YES;
            CLANG_WARN_UNREACHABLE_CODE = YES;
            CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
            COPY_PHASE_STRIP = YES;
            DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
            ENABLE_NS_ASSERTIONS = NO;
            ENABLE_STRICT_OBJC_MSGSEND = YES;
            GCC_C_LANGUAGE_STANDARD = gnu99;
            GCC_NO_COMMON_BLOCKS = YES;
            GCC_PRECOMPILE_PREFIX_HEADER = NO;
            GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
            GCC_WARN_ABOUT_RETURN_TYPE = YES;
            GCC_WARN_UNDECLARED_SELECTOR = YES;
            GCC_WARN_UNINITIALIZED_AUTOS = YES;
            GCC_WARN_UNUSED_FUNCTION = YES;
            GCC_WARN_UNUSED_VARIABLE = YES;
            IPHONEOS_DEPLOYMENT_TARGET = 12.0;
            MTL_ENABLE_DEBUG_INFO = NO;
            MTL_FAST_MATH = YES;
            SDKROOT = iphoneos;
            TARGETED_DEVICE_FAMILY = "1,2";
        };
            name = Release;
        };
        19786DAF1E8E4F5A00D94E8C /* Debug */ = {isa = PBXBuildConfiguration; buildSettings = {
            ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
            CLANG_ENABLE_MODULES = YES;
            INFOPLIST_FILE = "iOSInAppPurchaseDemo/Info.plist";
            IPHONEOS_DEPLOYMENT_TARGET = 12.0;
            PRODUCT_BUNDLE_IDENTIFIER = Liu.GameTest;
            PRODUCT_NAME = iOSInAppPurchaseDemo;
            TARGETED_DEVICE_FAMILY = "1,2";
        };
            name = Debug;
        };
        19786DB01E8E4F5A00D94E8C /* Release */ = {isa = PBXBuildConfiguration; buildSettings = {
            ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
            CLANG_ENABLE_MODULES = YES;
            INFOPLIST_FILE = "iOSInAppPurchaseDemo/Info.plist";
            IPHONEOS_DEPLOYMENT_TARGET = 12.0;
            PRODUCT_BUNDLE_IDENTIFIER = Liu.GameTest;
            PRODUCT_NAME = iOSInAppPurchaseDemo;
            TARGETED_DEVICE_FAMILY = "1,2";
        };
            name = Release;
        };
        /* End PBXBuildConfiguration section */

        /* Begin XCConfigurationList section */
        19786D901E8E4F5A00D94E8C /* Build configuration list for PBXProject "iOSInAppPurchaseDemo" */ = {isa = XCConfigurationList; buildConfigurations = (
            19786D9B1E8E4F5A00D94E8C /* Debug */,
            19786D9C1E8E4F5A00D94E8C /* Release */,
        ); defaultConfigurationIsVisible = 0;
            defaultConfigurationName = Release;
        };
        19786DAE1E8E4F5A00D94E8C /* Build configuration list for PBXNativeTarget "iOSInAppPurchaseDemo" */ = {isa = XCConfigurationList; buildConfigurations = (
            19786DAF1E8E4F5A00D94E8C /* Debug */,
            19786DB01E8E4F5A00D94E8C /* Release */,
        ); defaultConfigurationIsVisible = 0;
            defaultConfigurationName = Release;
        };
        /* End XCConfigurationList section */
    };
    rootObject = 19786D911E8E4F5A00D94E8C;
}

核心内容参考已经 写完,更多内容可以参考 开源出来的 文件

整个demo实例:

iOS 内购开发 (In-App Purchase) Objective-C Storyboard 实例

项目介绍

这是一个基于 Objective-C 和 Storyboard 的 iOS 内购开发示例项目,展示了如何在 iOS 应用中实现苹果应用内购买功能。

功能特性

  • 支持多个内购商品的展示和购买
  • 实现商品信息请求与展示
  • 完整的购买流程处理
  • 恢复已购买商品功能
  • 错误处理和状态反馈

技术架构

  • 开发语言: Objective-C
  • 界面设计: Storyboard
  • 内购框架: StoreKit.framework
  • 最低支持系统: iOS 12.0
  • Bundle Identifier: Liu.GameTest

项目结构

iOSInAppPurchaseDemo/
├── AppDelegate.h           # 应用程序委托头文件
├── AppDelegate.m           # 应用程序委托实现
├── ViewController.h        # 主视图控制器头文件
├── ViewController.m        # 主视图控制器实现(包含内购核心逻辑)
├── Info.plist              # 应用配置文件
└── Base.lproj/
    ├── Main.storyboard     # 主界面故事板
    └── LaunchScreen.storyboard # 启动界面故事板

内购商品配置

项目中配置了三个测试商品ID:

  • 001Liu
  • 002Liu
  • 003Liu

安装与运行

  1. 克隆或下载本项目
  2. 打开 iOSInAppPurchaseDemo.xcodeproj 文件
  3. 确保已在 Xcode 中配置开发者账号
  4. 在真机或模拟器上运行项目

注意事项

  1. 内购功能需要在真机上进行完整测试
  2. 在 App Store Connect 中配置相应的内购项目才能进行真实购买
  3. 项目包含测试用的 SubscriptionKey_Q9W29CG749.p8 文件
  4. 实际项目中请替换为自己的密钥文件和 Bundle Identifier

使用说明

  1. 启动应用后,系统会自动请求商品信息
  2. 点击对应商品的购买按钮进行购买
  3. 输入 Apple ID 密码完成支付
  4. 购买成功后会显示成功提示
  5. 可以点击”恢复购买”按钮恢复已购买的商品

开发提示

  • 确保在 Info.plist 中正确配置了应用的 Bundle Identifier
  • 在 App Store Connect 中创建相应的应用和内购项目
  • 测试内购时使用沙盒测试账号
  • 处理各种交易状态和错误情况

License

© 2025 优雅草科技. All rights reserved.

使用仓颉语言理解 SOLID 原则:概念、实战与踩坑总结

作者 unravel2025
2025年9月11日 20:23

为什么要学 SOLID?

在开发项目时,我们每天都在“改需求”:

  • 产品经理说“再加一种支付方式”
  • 后端说“换一套登录接口”
  • 设计师说“按钮样式统一换”

如果代码耦合严重,每一次改动都会牵一发动全身。

SOLID 五原则就是为了让“改动”变得安全、可预期、可测试,最终让“未来的自己”少掉两根头发。

SOLID 速览

缩写 全称 一句话记忆
SRP Single Responsibility Principle单一职责原则 一个类只负责一件事
OCP Open-Closed Principle开闭原则 对扩展开放,对修改关闭
LSP Liskov Substitution Principle里氏替换原则 子类必须能无缝替换父类
ISP Interface Segregation Principle接口隔离原则 别让客户端依赖它不需要的接口
DIP Dependency Inversion Principle依赖倒置原则 依赖抽象,而非具体实现

下面用 仓颉 代码逐一拆解

S – Single Responsibility Principle 单一职责原则

概念:一个类只能因为“一个原因”而改变。

反例:UserManager 同时管注册、发邮件、存数据库。

class User {}

// ❌ 违反 SRP:UserManager 负责三件事
class UserManager {
    func registerUser(email: String, password: String): Bool {
        // 1. 注册逻辑
        return true
    }

    func sendWelcomeEmail(to: String, user: User): Unit {
        // 2. 发邮件逻辑
    }

    func saveUserData(user: User): Unit {
        // 3. 持久化逻辑
    }
}

重构:把三件事拆成三个类,每个类只有一个“变更理由”。

// ✅ 负责注册
class UserRegistrationService {
    func register(email: String, password: String): Bool {
        // 仅处理注册
        return true
    }
}

// ✅ 负责邮件
class EmailService {
    func sendWelcomeEmail(to: String, user: User): Unit {
        // 仅处理邮件
    }
}

// ✅ 负责持久化
class UserRepository: Unit {
    func save(user: User) {
        // 仅处理数据库/磁盘
    }
}

收益:

  • 邮件模板要改?只动 EmailService
  • 存储换成 Realm?只动 UserRepository

O – Open-Closed Principle 开闭原则

概念:新增功能时,不修改老代码,只添加新代码。 反例:用 match 判断支付类型,每加一种支付方式都要改老文件。

// ❌ 违反 OCP:新增支付类型必须改 PaymentProcessor
class PaymentProcessor {
    func process(tp: String, amount: Float64): Unit {
        match (tp) {
            case "creditCard" => println("刷卡 ${amount}")
            case "paypal" => println("PayPal ${amount}")
            case _ => println("不支持")
        }
    }
}

重构:面向协议编程(POP),把“可变点”封装成协议。

// 1. 定义不变协议
interface PaymentMethod {
    func process(amount: Float64): Unit
}

// 2. 想加多少种支付就加多少类
class CreditCard <: PaymentMethod {
    public func process(amount: Float64): Unit {
        print("刷卡 ${amount}")
    }
}

class PayPal <: PaymentMethod {
    public func process(amount: Float64): Unit {
        print("PayPal ${amount}")
    }
}

// 3. 处理器对扩展永远关闭修改
class PaymentProcessor {
    func process(method: PaymentMethod, amount: Float64): Unit {
        method.process(amount) // 多态,无需 switch
    }
}

// 4. 新增 Apple Pay,老文件一行不动
class ApplePay <: PaymentMethod {
    public func process(amount: Float64): Unit {
        print("Apple Pay ${amount}")
    }
}

L – Liskov Substitution Principle 里氏替换原则

概念:父类出现的地方,子类必须能无异常替换;子类不能“违约”。 反例:Bird 协议要求会飞,Penguin 被迫实现 fly() 却 crash。

// ❌ 违反 LSP:Penguin 不能飞,却强行 override
open class Bird {
    public open func fly(): Unit {
        println("飞")
    }
}

class Penguin <: Bird {
    public override func fly(): Unit {
        throw Exception("企鹅不会飞!") // 运行时爆炸
    }
}

func makeFly(bird: Bird) {
    bird.fly() // 传 Penguin 会崩溃
}

重构:把“飞”行为抽象成更小的协议,让真正的“飞行者”去遵守。

// 1. 仅飞行者才需要实现
interface Flyable {
    func fly(): Unit
}

// 2. 企鹅不会飞,就不实现 Flyable
class Eagle <: Flyable {
    public func fly(): Unit {
        println("鹰击长空")
    }
}

class Penguin {
    public func swim(): Unit {
        println("企鹅游泳")
    }
}

// 3. 高阶函数只接受 Flyable,类型安全
func makeFly(f: Flyable): Unit {
    f.fly()
}

I – Interface Segregation Principle 接口隔离原则

概念:客户端不应被迫依赖它用不到的接口。 反例:Worker 协议同时要求 work() 和 eat(),机器人被迫空实现 eat()。

// ❌ 违反 ISP:Robot 不需要 eat,却必须“假实现”
interface Worker {
    func work(): Unit
    func eat(): Unit
}

class HumanWorker <: Worker {
    public func work(): Unit {
        print("人工 work")
    }
    public func eat(): Unit {
        print("人工 eat")
    }
}

class RobotWorker <: Worker {
    public func work(): Unit {
        print("机器人 work")
    }
    public func eat(): Unit { /* 机器人不吃,空实现 */ }
}

重构:拆成“小接口”,按需组合。

// 1. 行为细分
interface Workable {
    func work(): Unit
}

interface Eatable {
    func eat(): Unit
}

// 2. 人类两个都要
class HumanWorker <: Workable & Eatable {
    public func work(): Unit {
        print("人工 work")
    }
    public func eat(): Unit {
        print("人工 eat")
    }
}

// 3. 机器人只实现 Workable
class RobotWorker <: Workable {
    public func work(): Unit {
        print("机器人 work")
    }
}

D – Dependency Inversion Principle 依赖倒置原则

概念:

  1. 高层不依赖低层,二者都依赖抽象。
  2. 抽象不依赖细节,细节依赖抽象。

反例:DataManager 直接初始化 LowLevelStorage,换数据库要改高层。

// ❌ 违反 DIP:高层依赖具体实现
class LowLevelStorage {
    func store(data: String): Unit {
        println("存磁盘 ${data}")
    }
}

class DataManager {
    private let storage = LowLevelStorage() // 硬编码具体类
    func save(data: String): Unit {
        storage.store(data)
    }
}

重构:通过协议 + 依赖注入(DI)倒转依赖方向。

// 1. 抽象协议
interface Storage {
    func store(data: String): Unit
}

// 2. 具体实现
class DiskStorage <: Storage {
    public func store(data: String): Unit {
        println("存磁盘 ${data}")
    }
}

class CloudStorage <: Storage {
    public func store(data: String): Unit {
        println("存云端 ${data}")
    }
}

// 3. 高层只依赖抽象
class DataManager {
    DataManager(private let storage!: Storage) {}

    func save(data: String): Unit {
        storage.store(data)
    }
}

func demo(): Unit {
    // 4. 使用时自由切换
    let manager = DataManager(storage: CloudStorage())
    manager.save("Hello DIP")
}

实战总结与踩坑

  1. SRP 最难的是“粒度”

    拆太细 → 类爆炸;拆太粗 → UserManager类。

    判断标准:如果一段逻辑因为两个完全不同的需求而变更,就拆分。

  2. OCP 在 cangjie 最自然的工具是协议 + 泛型 + 扩展。

    可以在不修改原有方法实现的情况下新增实现

  3. LSP 常被忽略,尤其在“继承狂热”场景。

    建议:优先用组合+协议,少用继承;如果必须继承,子类只能加强、不能削弱父类行为(返回值更具体、异常更少)。

  4. ISP可以与仓颉的协议继承一起使用

    例如 public interface Equatable<T> <: Equal<T> & NotEqual<T> 就是 ISP 思想。

  5. DIP 是架构级别的“依赖倒转器”。

结语

SOLID 不是教条,而是“让代码拥抱变化”的底层思维。

当你习惯把“可能变化”的方向抽象成协议、把“单一理由”拆成独立模块、把“继承”换成“组合”,你会发现:

  • 单元测试更好写(Mock 协议即可)
  • Code Review 更少冲突(改动隔离)
  • 新人上手更快(类名即职责)

下一次需求变更来临,你可以优雅地新增一个文件,而不是在旧代码里“打补丁”。

提升生产力:每个 iOS 开发者都应该知道的 10 个 Xcode 技巧

作者 JarvanMo
2025年9月11日 09:31

欢迎关注我的公众号:OpenFlutter,感恩

在和 Swift、UIKit 以及现在的 SwiftUI 打交道多年后,我总结了一套在日常工作中真正能提升效率的小技巧。相信我,一旦你用了,就再也回不去了。

1. 代码片段库(⌘ + ⇧ + L)

我们总会写一些重复的代码,比如日志语句、SwiftUI 的样板代码、UserDefaults 的辅助函数,或者 weak self 捕获块等……这些都会累积起来。 与其一遍又一遍地敲相同的代码块,不如创建你自己的代码片段。 选中代码 → 将其拖动到代码片段库(右侧面板)。 为其设置一个快捷键,比如 logd → 它就会展开成你的调试日志代码。 下次,你只需输入 logd 并按下 Tab 键即可。 👉 结果:原本需要敲击 20 次的键盘,现在只需 4 次。


2. 多光标编辑(⌥ + 点击)

当你需要修改一个变量或修饰符时,不必再手动逐行编辑。 按住 Option (⌥) 键并点击多个位置,你就可以同时在多个地方进行输入。

例如:

Text("Hello")
    .font(.title)
    .foregroundColor(.blue)

Text("World")
    .font(.title)
    .foregroundColor(.blue)

想把两行代码中的 .blue 都改成 .red 吗?多光标编辑一步就能搞定。


3. 跳转到定义(⌘ + 点击)

这个功能被大大低估了。当你看到任何类、函数或变量时,只需按住 Command 键并点击,Xcode 就会直接带你到它的定义位置。

这个小小的操作看似微不足道,但在调试或学习新代码库时,它能帮你省去无数的滚动和搜索时间。

专业提示:使用 ⌘ + ⌥ + 点击,可以快速弹出一个小窗口,而无需离开当前文件。


4. 仅显示错误(⇧ + ⌘ + M)

大型项目意味着问题导航器里充斥着无休止的警告和杂乱信息。

按下 Shift + Command + M → Xcode 只会显示当前文件中的错误

再也不用为了找到真正的问题,而在一大堆废弃警告中翻找了。


5. 在作用域内全部编辑(⌃ + ⌘ + E)

当你重命名一个变量时,别再手动操作了。

将光标放在变量上 → 按下 Control + Command + E → Xcode 会高亮显示作用域内的所有引用。

只需输入一次,所有引用都会立即重命名。

这就像是无需打开菜单,就能拥有重构功能一样。


6. 在 Finder/终端中显示

在导航器中右击任何文件 → 选择 “Show in Finder”(在 Finder 中显示)或 “Show in Terminal”(在终端中显示)。

这个功能在以下情况下非常方便:

  • 你需要将某个资源拖到另一个项目中。
  • 你想快速 cd 进入该目录并运行 Git 命令。

7. 功能不止于暂停的断点

断点不仅仅是为了暂停代码运行。它们还可以:

  • 记录信息(而不停止执行)。
  • 触发脚本(比如打印变量或甚至运行 shell 命令)。

右击断点 → “Edit Breakpoint”(编辑断点)→ 添加一个动作。

例如:无需修改代码,就能在某个函数被调用时自动记录日志。


8. 辅助编辑器(⌘ + ⌥ + ↩)

在处理 SwiftUI 预览或相关的测试文件时,打开辅助编辑器可以并排查看两个文件。

例如:

  • 左侧:你的视图代码。
  • 右侧:预览文件。

现在,你对代码的每次微调都会实时显示,再也不用切换上百次标签页了。


9. 全局搜索(⇧ + ⌘ + O)

想不起来 fetchUserData() 函数在哪里?

按下 Shift + Command + O,输入名称,Xcode 就会直接跳转到它所在的位置。

这个功能适用于类、函数、枚举、故事板——基本上所有东西。它比在文件夹中滚动查找快得多。


10. 运行多个模拟器

你不需要一次只测试一个 iPhone 或 iPad UI。

进入 Window → Devices and Simulators(窗口 → 设备和模拟器),启动多个设备,然后同时在它们上面运行你的应用。

这对于测试响应式布局或及早发现 iPad 特定 bug 来说,非常有用。

`@preconcurrency` 完全导读:让旧代码平安驶上 Swift 并发快车道

作者 unravel2025
2025年9月11日 08:05

一、为什么会出现 @preconcurrency

Swift 5.5+ 的并发模型要求:

  • 跨任务传递的类型必须Sendable
  • 访问共享状态需隔离(@MainActor / actor
  • 编译器静态检查上述规则

但现实是:

  • 公司祖传框架写于 Swift 5.0
  • 第三方库没加 @Sendable / @MainActor
  • 系统 Objective-C 头文件更老

于是 Xcode 开始疯狂报红:

Type 'DataFetcher' does not conform to 'Sendable'
Call to main actor-isolated instance method in a synchronous context

@preconcurrency 就是“临时通行证”:

“老代码我保证安全使用,请先让我编译通过。”

二、能贴在哪儿?一张表看全

目标 示例 效果
class/struct/enum/actor @preconcurrency class Foo 整个类型视为 Sendable
protocol @preconcurrency protocol P 遵守者暂获豁免
extension @preconcurrency extension Foo 扩展内成员豁免
函数 @preconcurrency func f() 单个方法豁免
typealias @preconcurrency typealias T = Foo 别名豁免
import @preconcurrency import OldKit 整个模块一次性豁免

最常见:模块级 import 和 单个类型 声明。

三、实战:让 Swift 5.4 的老库通过 Swift 6 编译

  1. 老库源码(无法修改)
// OldKit.swift  (Swift 5.4)
public class DataFetcher {
    public func fetchData(completion: @escaping (String) -> Void) {
        DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
            completion("Legacy Data")
        }
    }
}
  1. Swift 6 客户端报红
import OldKit

Task {
    let fetcher = DataFetcher()
    fetcher.fetchData { print($0) }   // ❌ Type does not conform to Sendable
}
  1. 模块级通行证
@preconcurrency import OldKit   // ✅ 一次性静默警告

Task {
    let fetcher = DataFetcher()
    fetcher.fetchData { print($0) }   // 通过,但你负责线程安全
}

→ 整个模块所有符号暂时视为 Sendable,警告消失。

四、粒度更细:只给单类型/单函数开绿灯

若能改动源码,最小化豁免:

// 仅对单个类型
@preconcurrency
class LegacyCache {
    func save(_ data: Data) { /* 非线程安全 */ }
}

// 仅对单个函数
extension LegacyCache {
    @preconcurrency func clear() { /* 非线程安全 */ }
}

→ 调用侧同样静默,但影响面更小,便于后续逐步加固。

五、Swift 6 语言模式下的真实迁移流程

  1. 开启 -swift-version 6 → 报红爆炸
  2. 先加模块级 @preconcurrency import → 编译通过
  3. 单元测试/静态分析确保无数据竞争
  4. 逐步给类型加上 Sendable / @MainActor
  5. 删除细粒度 @preconcurrency → 完成现代化

六、安全准则:通行证 ≠ 免死金牌

✅ 安全使用 checklist

  • 类型无共享可变状态(或已加锁)
  • 函数不访问全局变量/单例
  • 回调仅在内部同步执行,不逃逸

❌ 危险示例

@preconcurrency class UnsafeCache {
    var dict: [String: Any] = [:]   // 可变 + 非 Sendable
}

→ 虽然编译通过,但多个 Task 同时写 dict 仍会数据竞争!

七、与 @Sendable 的协作关系

策略 编译通过 线程安全保证 推荐场景
@preconcurrency 人工负责 迁移过渡期
真正 Sendable 编译器检查 长期目标

口诀: “先通行证,后真护照;先上车,后补票。”

八、常见编译错误对照

错误 原因 修复
@preconcurrencycannot be applied to this declaration 贴在不被支持的声明上 改用模块级 import 或换声明类型
Type 'Foo' does not conform to 'Sendable' 未加通行证 在 import 或类型前加 @preconcurrency
Call to main actor-isolated instance method in a synchronous context 老代码在主线程外调 UI 把调用包进 MainActor.run或给方法加 @MainActor

九、一句话总结

@preconcurrency = “老代码的临时通行证”,

它让 Swift 6 的严格检查暂时闭嘴, 但线程安全责任从此落到你的肩上!

记住口诀:

“模块 import 加头顶,单类型声明也 OK;先让项目跑起来,再逐步 Sendable。”

用好这张“临时驾照”,让祖传代码平稳驶上 Swift 并发的快车道——既不掉链,也不甩锅。

调试 Swift 并发:我到底在哪个 Actor?

作者 unravel2025
2025年9月11日 08:04

一、Swift 6 的“灵魂拷问”

写异步代码时你想知道:

“我现在是不是在主线程?”

于是老习惯:

print(Thread.isMainThread ? "主线程" : "后台")

Swift 6 直接报错:

'Thread.isMainThread' is unavailable from asynchronous contexts  
Work intended for the main actor should be marked with @MainActor

→ 别再关心线程,Swift 并发里正确问题是:

“我现在在哪个 Actor?”

二、为什么“线程”不够用了?

  • Swift 并发 = Actor 隔离模型
  • 同一 Actor 的任务可跑在不同线程(全局执行器调度)
  • 唯一安全保证 =“是否处于预期隔离域”,而非具体线程
旧概念/方法 Swift 6 新思维/方法
Thread.isMainThread MainActor.assertIsolated()
“跑在主线程” “跑在 MainActor”
“手动切线程” “让编译器/运行时调度”

三、调试神器:MainActor.assertIsolated()

func updateUI() {
    MainActor.assertIsolated("UI 必须在 MainActor 上更新")
    title = "Loaded"
}

行为:

构建配置 结果
Debug(Xcode 默认) 如果不在 MainActor → 立即 trap(可看到堆栈)
Release 无代码,零成本

陷阱演示

Task { // ⛔️ 后台 actor
    updateUI()   // Debug 下立刻崩溃
}

崩溃信息示例:

Task 1 Queue: com.apple.root.user-initiated-qos.cooperative (concurrent)

→ 队列名带 user-initiated + 无 main-thread → 确凿后台环境。

四、想“硬崩溃”用 preconditionIsolated()

MainActor.preconditionIsolated("生产环境也必须主线程")
  • Debug & Release 都会 crash
  • 用于“一旦跑错隔离域就是逻辑错误”的场景(如 UI 刷新)

五、自定义 Global Actor 也能断言

@globalActor
actor ImageCacheActor {
    static let shared = ImageCacheActor()
}

@ImageCacheActor
func mutateCache() {
    ImageCacheActor.assertIsolated("必须在我自己的隔离域")
    // 安全操作缓存
}

→ 与 MainActor 用法完全一致,调试信息同样显示队列名:

Queue: com.apple.root.default-qos.cooperative (concurrent)

六、快速判断“我不在 MainActor”

Swift 没有 assertNotMainActor,但可以反向利用:

#if DEBUG
// 临时检查:如果这里不崩溃,说明**在** MainActor → 证明我们跑错地方
MainActor.assertIsolated("应该 NOT 在主线程执行")
#endif

调试点技巧:

  1. 在断点处看 Debug Navigator → Queue

  2. 队列名含 main-thread → MainActor

    否则 → 后台隔离域

七、日志可视化:把隔离域打印出来

#if DEBUG
func logIsolation(_ tag: String = "") {
    let queueLabel = DispatchQueue.getSpecific(key: DispatchSpecificKey()) ?? "unknown"
    print("[\(tag)] Queue: \(queueLabel)  Thread: \(Thread.current)")
}
#endif

→ 结合 assertIsolated 可一次性确认“队列 + 线程 + 崩溃行”。

八、常见疑问速答

疑问 解答
“我用 Task { @MainActor in }就够了吧?” 那是提交任务时指定,调试时仍需确认内部是否确实在主线程
Thread.isMainThread真的不能用了吗?” Swift 6 语言模式下编译错误;用 MainActor.assertIsolated()替代
“断言会影响性能吗?” Release 下 assertIsolated无代码;preconditionIsolated才会留

九、一句话总结

“Swift 6 里,别再问‘我在哪个线程’,而应问‘我在哪个 Actor’。”

记住调试三步曲:

  1. 开发期:MainActor.assertIsolated("描述") → 早崩溃早修复
  2. 调试器:看 Queue 名 → main-thread 即安全
  3. 生产期:如逻辑错误必崩 → 换 preconditionIsolated

把“线程思维”换成“Actor 思维”,让编译器 + 运行时替你守好并发安全的大门!

苹果卡审情况将逐步缓解,合规的开发者请耐心等待~

作者 iOS研究院
2025年9月10日 17:20

背景

最近陆陆续续的有粉丝来询问关于AppStore审核时间久的问题。上周五也经历了一次历时30个小时的正在审核中

主要集中在长期卡在正在审核中!

简单来说:

苹果每次发布会来临之际,都会对AppStore审核上强度。毕竟在这种重大事件上,苹果公关部也不希望因为违规的产品,对新品发布造成影响。

最近审核情况

因为最近也有产品在迭代,所以直接上截图,让正在审核的同行们不必担心!

迭代第一次

审核时间约等于1.5小时

2.13.1.png

迭代第二次

审核时间约等于30小时

2.13.2.png

今日迭代

审核时间约等于0.5小时

2.13.3.png

额外说明

如果长期时间没有结果,现在正在审核中,可考虑直接撤销重新提交。因为大概率苹果把人力放在筹备发布会

在此关键时间建议不要过分的ASO操作与开放违规功能,切记不可顶风作案

遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!

相关推荐

# 苹果开发者续费大坑及成功续费方案!亲测有效

# Pingpong和连连的平替,让AppStore收款无需新增持有人。

# 期待iOS开发者加入,共同抵制“苹果税”反垄断招募令!

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

知识星球

更多Appstore咨询问题,请关注知识星球。「提供1v1上架指导,帮助开发者解决Appstore的疑难杂症,助力每一位开发者!」

iOS GitSubModule CocoaPod 制作私有源本地组件库

作者 LDelon
2025年9月10日 12:15

制作组件库

创建本地POD库

pod lib create [你的库名称]

What platform do you want to use?? [ iOS / macOS ]
> iOS (选择平台,这里以iOS为例)
What language do you want to use?? [ Swift / ObjC ]
> Swift (选择开发语言)
Would you like to include a demo application with your library? [ Yes / No ]
> No (是否包含示例项目,推荐选Yes,方便测试)
Which testing frameworks will you use? [ Quick / None ]
> None (测试框架,新手可选None)
Would you like to do view based testing? [ Yes / No ]
> No (是否需要视图测试,选No)

创建后

截屏2025-09-10 10.59.26.png

TestLib.podspec Pod库描述文件(核心)
_Pods.xcodeproj 自动生成的Pods项目
Example 示例项目
TestLib 源代码
LICENSE 许可证文件
Classes 存放库的源代码
ReplaceMe.swift 默认生成的示例文件(需替换)

Pod描述文件内容
必填字段(缺少会导致验证失败):

s.name:库名称,必须与 .podspec 文件名一致
s.version:版本号,需遵循语义化版本(如 1.0.0)
s.summary:简短描述(140 字符以内)
s.description:详细描述(比 summary 更完整)
s.homepage:项目主页 URL
s.license:许可证类型(如 MIT)及许可证文件路径
s.author:作者信息
s.source:源代码仓库地址(Git 仓库 URL)及标签
s.platform / s.ios.deployment_target:支持的平台及最低版本
s.source_files:源代码文件的路径(用于指定哪些文件会被打包)

常用可选字段:

s.swift_version:指定 Swift 版本(如 5.5)
s.resource_bundles / s.resource:配置资源文件(图片、XIB 等)
s.dependency:声明依赖的其他库(第三方或私有库)
s.static_framework:强制生成静态框架(适用于混编项目)
s.subspec:将库拆分为子模块(如按功能拆分 Core / UI 模块)
  • 将库的源代码都放入Classes目录下
  • 将库的图片资源都放入Assets目录下

添加远程仓库配置,提交代码并打标签

git remote add origin github.com/XXX/TestLib…

git add .
git commit -m "Initial commit"
git tag 0.1.0
git push -u origin main

主工程添加子模块

  • git submodule add [远程仓库] [本地路径]
  • 例:git submodule add github.com/XXX/TestLib SubModule/TestLib

截屏2025-09-10 11.55.47.png

主工程会在根目录创建 SubModule,并在 SubModule 下面添加 TestLib 子模块
上面的 [本地路径] :SubModule/TestLib 路径会在项目PodFile的配置中用到

集成本地Pod

在主工程的 Podfile 中,引入此组件库

  • pod 'TestLib', :path => 'SubModule/TestLib'

path 填写上一步中添加的本地路径SubModule/TestLib

  • 主工程 pod install

截屏2025-09-10 12.04.32.png

大功告成啦!

组件化利器,后面只需要不断更新SubModule中的代码就可以了

Flutter UI Components:闲来无事,设计整理了这几年来使用的UI组件库

2025年9月9日 11:57

本文详细介绍了一个完整的 Flutter UI 组件库的设计思路、架构实现和核心特性,包含 50+ 个高质量组件,支持主题切换、响应式设计等企业级功能。

📋 目录

🎯 项目概述

Flutter UI Components 是一个基于 Flutter 框架开发的企业级 UI 组件库,旨在为开发者提供一套完整、可定制、高性能的 UI 组件解决方案。

核心特性

  • 🎨 完整的主题系统:支持浅色/深色主题切换,自动适配系统主题
  • 📱 响应式设计:适配不同屏幕尺寸,支持平板和桌面端
  • 🔧 高度可定制:丰富的配置选项,满足各种设计需求
  • 性能优化:使用 const 构造函数,减少不必要的重建
  • 🧪 完整测试:单元测试和 Widget 测试覆盖
  • 🏗️ 模块化架构:清晰的目录结构,便于维护和扩展

组件统计

分类 组件数量 主要组件
按钮组件 5+ UIButton、UIGradientButton、UIHighlightButton
卡片组件 3+ UIDefaultCard、UICollectionView
导航组件 2+ UIAppBarDecorator、UISegmentedControl
反馈组件 8+ UIToolTip、UIToast、UIDialog、UISnackBar
数据展示 6+ UITagView、UIBadgeView、UIProgressIndicator
输入组件 4+ UITextField、UICheckBox、UIDropDownButton
布局组件 5+ UIListView、UIGridView、UITableView

🏗️ 系统架构设计

整体架构图

graph TB
    A[Flutter UI Components] --> B[Core Module 核心模块]
    A --> C[Components Module 组件模块]

    B --> D[Base Classes 基础类]
    B --> E[Theme System 主题系统]
    B --> F[Constants 常量定义]
    B --> G[Extensions 扩展方法]

    C --> H[Buttons 按钮组件]
    C --> I[Cards 卡片组件]
    C --> J[Navigation 导航组件]
    C --> K[Feedback 反馈组件]
    C --> L[Data Display 数据展示]
    C --> M[Input 输入组件]
    C --> N[Layout 布局组件]

    D --> O[BaseWidget]
    D --> P[BaseStatefulWidget]
    D --> Q[UIBasePage]

    E --> R[AppTheme]
    E --> S[ThemeConfig]
    E --> T[ThemeBuilder]

模块依赖关系

graph LR
    A[Components] --> B[Core]
    B --> C[Flutter Framework]

    D[BaseWidget] --> E[Theme System]
    D --> F[Constants]

    G[UI Components] --> D
    G --> H[Extensions]

    I[Example App] --> A
    I --> J[GetX State Management]

🔧 核心模块详解

1. 基础类设计

所有组件都继承自 BaseWidget,确保统一的接口和生命周期管理:

/// 基础Widget抽象类
/// 所有UI组件都应该继承此类,确保统一的接口和生命周期管理
abstract class BaseWidget extends StatelessWidget {
  const BaseWidget({super.key});

  /// 组件名称,用于调试和日志
  String get widgetName;

  /// 组件版本,用于版本管理
  String get version => '1.0.0';

  /// 是否启用调试模式
  bool get enableDebug => false;

  @override
  Widget build(BuildContext context) {
    if (enableDebug) {
      debugPrint('Building $widgetName v$version');
    }
    return buildWidget(context);
  }

  /// 子类需要实现的构建方法
  Widget buildWidget(BuildContext context);

  /// 获取组件的主题数据
  ThemeData getTheme(BuildContext context) {
    return Theme.of(context);
  }

  /// 获取组件的主题配置
  AppThemeConfig getThemeConfig(BuildContext context) {
    final theme = getTheme(context);
    return AppThemeConfig.fromBrightness(theme.brightness);
  }
}

2. 主题系统架构

主题系统采用接口抽象和配置分离的设计模式:

/// 主题配置接口
abstract class IThemeConfig {
  Color get primary;
  Color get secondary;
  Color get success;
  Color get error;
  Color get text;
  Color get background;
  Color get surface;
  // ... 更多颜色定义
}

/// 应用主题管理类
class AppTheme {
  static AppTheme? _instance;
  static AppTheme get instance => _instance ??= AppTheme._();

  /// 当前主题模式
  ThemeMode _themeMode = ThemeMode.system;

  /// 当前主题配置
  IThemeConfig? _customConfig;

  /// 主题配置构建器
  IThemeConfig Function(Brightness brightness)? _configBuilder;

  /// 获取当前主题配置
  IThemeConfig get currentConfig {
    if (_customConfig != null) {
      return _customConfig!;
    }

    if (_configBuilder != null) {
      final brightness = _themeMode == ThemeMode.dark
          ? Brightness.dark : Brightness.light;
      return _configBuilder!(brightness);
    }

    // 返回默认配置
    return _themeMode == ThemeMode.dark
        ? const DefaultDarkThemeConfig()
        : const DefaultThemeConfig();
  }

  /// 切换主题模式
  void switchTheme(ThemeMode mode) {
    if (_themeMode != mode) {
      _themeMode = mode;
      _notifyThemeListeners();
    }
  }
}

3. 常量系统设计

统一的常量管理确保设计一致性:

/// 应用常量定义
class AppConstants {
  // 间距常量
  static const double spacingXs = 4.0;
  static const double spacingSm = 8.0;
  static const double spacingMd = 16.0;
  static const double spacingLg = 24.0;
  static const double spacingXl = 32.0;

  // 圆角常量
  static const double radiusXs = 4.0;
  static const double radiusSm = 8.0;
  static const double radiusMd = 12.0;
  static const double radiusLg = 16.0;

  // 字体大小常量
  static const double fontSizeXs = 12.0;
  static const double fontSizeSm = 14.0;
  static const double fontSizeMd = 16.0;
  static const double fontSizeLg = 18.0;
  static const double fontSizeXl = 20.0;

  // 动画时长常量
  static const int animationDurationFast = 200;
  static const int animationDurationMd = 300;
  static const int animationDurationSlow = 500;
}

🎨 组件分类与实现

1. 按钮组件

按钮组件支持多种类型、尺寸和状态:

/// 按钮类型枚举
enum UIButtonType {
  primary,    // 主要按钮
  secondary,  // 次要按钮
  success,    // 成功按钮
  warning,    // 警告按钮
  error,      // 错误按钮
  info,       // 信息按钮
  outline,    // 轮廓按钮
  text,       // 文本按钮
}

/// 通用按钮组件
class UIButton extends BaseWidget {
  const UIButton({
    super.key,
    required this.text,
    this.onPressed,
    this.type = UIButtonType.primary,
    this.size = UIButtonSize.medium,
    this.isLoading = false,
    this.isDisabled = false,
    this.icon,
    this.fullWidth = false,
  });

  final String text;
  final VoidCallback? onPressed;
  final UIButtonType type;
  final UIButtonSize size;
  final bool isLoading;
  final bool isDisabled;
  final IconData? icon;
  final bool fullWidth;

  @override
  String get widgetName => 'UIButton';

  @override
  Widget buildWidget(BuildContext context) {
    final themeConfig = getThemeConfig(context);
    final isEnabled = onPressed != null && !isDisabled && !isLoading;

    return SizedBox(
      width: fullWidth ? double.infinity : null,
      child: ElevatedButton(
        onPressed: isEnabled ? onPressed : null,
        style: _buildButtonStyle(themeConfig),
        child: _buildButtonContent(themeConfig),
      ),
    );
  }

  ButtonStyle _buildButtonStyle(AppThemeConfig config) {
    return ElevatedButton.styleFrom(
      backgroundColor: _getBackgroundColor(config),
      foregroundColor: _getForegroundColor(config),
      padding: _getPadding(),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(AppConstants.radiusSm),
      ),
      elevation: type == UIButtonType.outline ? 0 : 2,
    );
  }
}

2. 工具提示组件

工具提示组件支持多方向显示和丰富的自定义选项:

/// 工具提示方向枚举
enum UIToolTipDirection {
  up, down, left, right,
  upLeft, upRight, downLeft, downRight,
}

/// 工具提示配置
class UIToolTipConfig {
  const UIToolTipConfig({
    this.direction = UIToolTipDirection.down,
    this.distance = 8.0,
    this.arrowSize = 8.0,
    this.backgroundColor = Colors.black87,
    this.textColor = Colors.white,
    this.borderRadius = 8.0,
    this.padding = const EdgeInsets.all(12.0),
    this.showArrow = true,
    this.animationDuration = const Duration(milliseconds: 200),
  });

  final UIToolTipDirection direction;
  final double distance;
  final double arrowSize;
  final Color backgroundColor;
  final Color textColor;
  final double borderRadius;
  final EdgeInsets padding;
  final bool showArrow;
  final Duration animationDuration;
}

/// 主要的工具提示组件
class UIToolTip extends StatefulWidget {
  const UIToolTip({
    super.key,
    required this.content,
    this.config = const UIToolTipConfig(),
    this.controller,
    required this.child,
  });

  final Widget content;
  final UIToolTipConfig config;
  final UIToolTipController? controller;
  final Widget child;

  @override
  State<UIToolTip> createState() => _UIToolTipState();
}

3. 渐变色进度指示器

自定义绘制的渐变色圆形进度指示器:

/// 渐变色圆形进度指示器组件
class UIGradientCircularProgressIndicator extends StatelessWidget {
  const UIGradientCircularProgressIndicator({
    super.key,
    required this.radius,
    this.strokeWidth = 4.0,
    this.colors,
    this.stops,
    this.strokeCapRound = false,
    this.backgroundColor = const Color(0xFFEEEEEE),
    this.totalAngle = 2 * pi,
    this.value,
    this.animationDuration = const Duration(milliseconds: 300),
    this.child,
  });

  final double radius;
  final double strokeWidth;
  final List<Color>? colors;
  final List<double>? stops;
  final bool strokeCapRound;
  final Color backgroundColor;
  final double totalAngle;
  final double? value;
  final Duration animationDuration;
  final Widget? child;

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      size: Size(radius * 2, radius * 2),
      painter: _GradientCircularProgressPainter(
        strokeWidth: strokeWidth,
        colors: colors ?? [Theme.of(context).primaryColor],
        stops: stops,
        strokeCapRound: strokeCapRound,
        backgroundColor: backgroundColor,
        totalAngle: totalAngle,
        value: value,
      ),
      child: child,
    );
  }
}

class _GradientCircularProgressPainter extends CustomPainter {
  _GradientCircularProgressPainter({
    required this.strokeWidth,
    required this.colors,
    this.stops,
    required this.strokeCapRound,
    required this.backgroundColor,
    required this.totalAngle,
    this.value,
  });

  final double strokeWidth;
  final List<Color> colors;
  final List<double>? stops;
  final bool strokeCapRound;
  final Color backgroundColor;
  final double totalAngle;
  final double? value;

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = (size.width - strokeWidth) / 2;

    // 绘制背景圆环
    final backgroundPaint = Paint()
      ..color = backgroundColor
      ..strokeWidth = strokeWidth
      ..style = PaintingStyle.stroke
      ..strokeCap = strokeCapRound ? StrokeCap.round : StrokeCap.butt;

    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      -pi / 2,
      totalAngle,
      false,
      backgroundPaint,
    );

    // 绘制进度圆环
    if (value != null && value! > 0) {
      final progressPaint = Paint()
        ..strokeWidth = strokeWidth
        ..style = PaintingStyle.stroke
        ..strokeCap = strokeCapRound ? StrokeCap.round : StrokeCap.butt;

      if (colors.length == 1) {
        progressPaint.color = colors.first;
      } else {
        final gradient = SweepGradient(
          colors: colors,
          stops: stops,
        );
        progressPaint.shader = gradient.createShader(
          Rect.fromCircle(center: center, radius: radius),
        );
      }

      canvas.drawArc(
        Rect.fromCircle(center: center, radius: radius),
        -pi / 2,
        totalAngle * value!,
        false,
        progressPaint,
      );
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

🎨 主题系统设计

主题切换流程图

flowchart TD
    A[用户触发主题切换] --> B[AppTheme.switchTheme]
    B --> C[更新_themeMode]
    C --> D[通知主题监听器]
    D --> E[UI组件重新构建]
    E --> F[应用新主题样式]

    G[系统主题变化] --> H[监听系统主题]
    H --> I[自动切换主题模式]
    I --> B

    J[自定义主题配置] --> K[setCustomConfig]
    K --> L[更新_customConfig]
    L --> D

主题配置示例

/// 自定义主题配置示例
class CustomThemeConfig implements IThemeConfig {
  const CustomThemeConfig();

  @override
  Color get primary => const Color(0xFF6366F1); // 靛蓝色

  @override
  Color get secondary => const Color(0xFF8B5CF6); // 紫色

  @override
  Color get success => const Color(0xFF10B981); // 绿色

  @override
  Color get error => const Color(0xFFEF4444); // 红色

  @override
  Color get warning => const Color(0xFFF59E0B); // 橙色

  @override
  Color get info => const Color(0xFF3B82F6); // 蓝色

  @override
  Color get text => const Color(0xFF1F2937); // 深灰色

  @override
  Color get textSecondary => const Color(0xFF6B7280); // 中灰色

  @override
  Color get background => const Color(0xFFF9FAFB); // 浅灰色

  @override
  Color get surface => Colors.white;

  @override
  Color get divider => const Color(0xFFE5E7EB); // 分割线颜色

  @override
  Color get border => const Color(0xFFD1D5DB); // 边框颜色

  @override
  Color get disabled => const Color(0xFF9CA3AF); // 禁用颜色

  @override
  Color get disabledLight => const Color(0xFFF3F4F6); // 浅禁用颜色

  @override
  Color get tips => const Color(0xFFF59E0B); // 提示颜色

  @override
  Color get dialogText => const Color(0xFF1F2937); // 对话框文本

  @override
  Color get lightBlue => const Color(0xFFDBEAFE); // 浅蓝色

  @override
  Color get lighterBlue => const Color(0xFFEFF6FF); // 更浅蓝色

  @override
  Color get lightGray => const Color(0xFFF3F4F6); // 浅灰色
}

/// 使用自定义主题
void setupCustomTheme() {
  AppTheme.instance.setCustomConfig(const CustomThemeConfig());
}

⚡ 性能优化策略

1. 组件优化

/// 使用 const 构造函数优化
class UIButton extends BaseWidget {
  const UIButton({
    super.key,
    required this.text,
    this.onPressed,
    this.type = UIButtonType.primary,
    this.size = UIButtonSize.medium,
    this.isLoading = false,
    this.isDisabled = false,
    this.icon,
    this.fullWidth = false,
  });

  // 使用 const 构造函数可以避免不必要的重建
  static const UIButton primaryButton = UIButton(
    text: 'Primary',
    type: UIButtonType.primary,
  );
}

2. 列表优化

/// 使用 ListView.builder 优化长列表
class UIListView extends StatelessWidget {
  const UIListView({
    super.key,
    required this.itemCount,
    required this.itemBuilder,
    this.separatorBuilder,
    this.padding,
    this.physics,
  });

  final int itemCount;
  final Widget Function(BuildContext context, int index) itemBuilder;
  final Widget Function(BuildContext context, int index)? separatorBuilder;
  final EdgeInsetsGeometry? padding;
  final ScrollPhysics? physics;

  @override
  Widget build(BuildContext context) {
    return ListView.separated(
      padding: padding,
      physics: physics,
      itemCount: itemCount,
      itemBuilder: itemBuilder,
      separatorBuilder: separatorBuilder ?? (context, index) => const SizedBox.shrink(),
    );
  }
}

3. 动画优化

/// 使用 AnimationController 优化动画性能
class _UIToolTipState extends State<UIToolTip>
    with TickerProviderStateMixin {
  late AnimationController _animationController;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(
      duration: widget.config.animationDuration,
      vsync: this,
    );
    _animation = CurvedAnimation(
      parent: _animationController,
      curve: Curves.easeInOut,
    );
  }

  @override
  void dispose() {
    _animationController.dispose();
    super.dispose();
  }
}

🧪 测试与质量保证

1. 单元测试示例

import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_ui_components/flutter_ui_components.dart';

void main() {
  group('UIButton Tests', () {
    testWidgets('应该渲染带有正确文本的按钮', (tester) async {
      // Arrange
      const buttonText = '测试按钮';

      // Act
      await tester.pumpWidget(
        MaterialApp(
          home: Scaffold(
            body: UIButton(
              text: buttonText,
              onPressed: () {},
            ),
          ),
        ),
      );

      // Assert
      expect(find.text(buttonText), findsOneWidget);
    });

    testWidgets('禁用状态下按钮应该不可点击', (tester) async {
      // Arrange
      bool wasPressed = false;

      // Act
      await tester.pumpWidget(
        MaterialApp(
          home: Scaffold(
            body: UIButton(
              text: '禁用按钮',
              onPressed: () => wasPressed = true,
              isDisabled: true,
            ),
          ),
        ),
      );

      await tester.tap(find.text('禁用按钮'));
      await tester.pump();

      // Assert
      expect(wasPressed, false);
    });

    testWidgets('加载状态下应该显示加载指示器', (tester) async {
      // Act
      await tester.pumpWidget(
        MaterialApp(
          home: Scaffold(
            body: UIButton(
              text: '加载按钮',
              onPressed: () {},
              isLoading: true,
            ),
          ),
        ),
      );

      // Assert
      expect(find.byType(CircularProgressIndicator), findsOneWidget);
    });
  });
}

2. Widget 测试示例

void main() {
  group('UIToolTip Tests', () {
    testWidgets('应该显示工具提示内容', (tester) async {
      // Arrange
      const tooltipContent = '这是一个工具提示';

      // Act
      await tester.pumpWidget(
        MaterialApp(
          home: Scaffold(
            body: UIToolTip(
              content: const Text(tooltipContent),
              child: const Text('悬停我'),
            ),
          ),
        ),
      );

      // 触发悬停
      await tester.longPress(find.text('悬停我'));
      await tester.pumpAndSettle();

      // Assert
      expect(find.text(tooltipContent), findsOneWidget);
    });

    testWidgets('应该根据配置显示箭头', (tester) async {
      // Act
      await tester.pumpWidget(
        MaterialApp(
          home: Scaffold(
            body: UIToolTip(
              content: const Text('带箭头的提示'),
              config: const UIToolTipConfig(showArrow: true),
              child: const Text('悬停我'),
            ),
          ),
        ),
      );

      await tester.longPress(find.text('悬停我'));
      await tester.pumpAndSettle();

      // Assert
      expect(find.byType(CustomPaint), findsWidgets);
    });
  });
}

📱 使用示例

1. 基础使用

import 'package:flutter/material.dart';
import 'package:flutter_ui_components/flutter_ui_components.dart';

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter UI Components Demo',
      theme: AppTheme.instance.lightTheme,
      darkTheme: AppTheme.instance.darkTheme,
      themeMode: AppTheme.instance.themeMode,
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('UI Components Demo'),
        actions: [
          IconButton(
            icon: const Icon(Icons.brightness_6),
            onPressed: () {
              // 切换主题
              final currentMode = AppTheme.instance.themeMode;
              final newMode = currentMode == ThemeMode.light
                  ? ThemeMode.dark
                  : ThemeMode.light;
              AppTheme.instance.switchTheme(newMode);
            },
          ),
        ],
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // 按钮组件示例
            _buildSectionTitle('按钮组件'),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                UIButton(
                  text: '主要按钮',
                  onPressed: () => _showToast('主要按钮被点击'),
                  type: UIButtonType.primary,
                ),
                UIButton(
                  text: '成功按钮',
                  onPressed: () => _showToast('成功按钮被点击'),
                  type: UIButtonType.success,
                ),
                UIButton(
                  text: '警告按钮',
                  onPressed: () => _showToast('警告按钮被点击'),
                  type: UIButtonType.warning,
                ),
                UIButton(
                  text: '错误按钮',
                  onPressed: () => _showToast('错误按钮被点击'),
                  type: UIButtonType.error,
                ),
                UIButton(
                  text: '轮廓按钮',
                  onPressed: () => _showToast('轮廓按钮被点击'),
                  type: UIButtonType.outline,
                ),
                UIButton(
                  text: '文本按钮',
                  onPressed: () => _showToast('文本按钮被点击'),
                  type: UIButtonType.text,
                ),
              ],
            ),

            const SizedBox(height: 24),

            // 卡片组件示例
            _buildSectionTitle('卡片组件'),
            UIDefaultCard(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text(
                    '卡片标题',
                    style: TextStyle(
                      fontSize: 18,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  const SizedBox(height: 8),
                  const Text('这是一个卡片组件的示例内容。'),
                  const SizedBox(height: 16),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.end,
                    children: [
                      UIButton(
                        text: '取消',
                        type: UIButtonType.text,
                        onPressed: () {},
                      ),
                      const SizedBox(width: 8),
                      UIButton(
                        text: '确定',
                        onPressed: () {},
                      ),
                    ],
                  ),
                ],
              ),
            ),

            const SizedBox(height: 24),

            // 工具提示组件示例
            _buildSectionTitle('工具提示组件'),
            Center(
              child: UIToolTip(
                content: const Text('这是一个工具提示'),
                config: const UIToolTipConfig(
                  direction: UIToolTipDirection.down,
                  backgroundColor: Colors.black87,
                  textColor: Colors.white,
                ),
                child: UIButton(
                  text: '悬停查看提示',
                  onPressed: () {},
                ),
              ),
            ),

            const SizedBox(height: 24),

            // 进度指示器示例
            _buildSectionTitle('进度指示器'),
            Center(
              child: Column(
                children: [
                  UIGradientCircularProgressIndicator(
                    radius: 50,
                    value: 0.7,
                    colors: const [
                      Colors.blue,
                      Colors.purple,
                      Colors.pink,
                    ],
                    child: const Center(
                      child: Text(
                        '70%',
                        style: TextStyle(
                          fontSize: 16,
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                    ),
                  ),
                  const SizedBox(height: 16),
                  UIProgressIndicator(
                    value: 0.6,
                    backgroundColor: Colors.grey[300]!,
                    valueColor: Colors.blue,
                  ),
                ],
              ),
            ),

            const SizedBox(height: 24),

            // 标签组件示例
            _buildSectionTitle('标签组件'),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                UITagView(
                  text: '默认标签',
                  onTap: () => _showToast('默认标签被点击'),
                ),
                UITagView(
                  text: '主要标签',
                  type: UITagType.primary,
                  onTap: () => _showToast('主要标签被点击'),
                ),
                UITagView(
                  text: '成功标签',
                  type: UITagType.success,
                  onTap: () => _showToast('成功标签被点击'),
                ),
                UITagView(
                  text: '警告标签',
                  type: UITagType.warning,
                  onTap: () => _showToast('警告标签被点击'),
                ),
                UITagView(
                  text: '错误标签',
                  type: UITagType.error,
                  onTap: () => _showToast('错误标签被点击'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildSectionTitle(String title) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 16),
      child: Text(
        title,
        style: const TextStyle(
          fontSize: 20,
          fontWeight: FontWeight.bold,
        ),
      ),
    );
  }

  void _showToast(String message) {
    // 这里可以使用 UIToast 组件显示提示
    debugPrint('Toast: $message');
  }
}

2. 高级使用

/// 自定义主题配置
class MyCustomThemeConfig implements IThemeConfig {
  const MyCustomThemeConfig();

  @override
  Color get primary => const Color(0xFF6366F1);

  @override
  Color get secondary => const Color(0xFF8B5CF6);

  @override
  Color get success => const Color(0xFF10B981);

  @override
  Color get error => const Color(0xFFEF4444);

  @override
  Color get warning => const Color(0xFFF59E0B);

  @override
  Color get info => const Color(0xFF3B82F6);

  @override
  Color get text => const Color(0xFF1F2937);

  @override
  Color get textSecondary => const Color(0xFF6B7280);

  @override
  Color get background => const Color(0xFFF9FAFB);

  @override
  Color get surface => Colors.white;

  @override
  Color get divider => const Color(0xFFE5E7EB);

  @override
  Color get border => const Color(0xFFD1D5DB);

  @override
  Color get disabled => const Color(0xFF9CA3AF);

  @override
  Color get disabledLight => const Color(0xFFF3F4F6);

  @override
  Color get tips => const Color(0xFFF59E0B);

  @override
  Color get dialogText => const Color(0xFF1F2937);

  @override
  Color get lightBlue => const Color(0xFFDBEAFE);

  @override
  Color get lighterBlue => const Color(0xFFEFF6FF);

  @override
  Color get lightGray => const Color(0xFFF3F4F6);
}

/// 应用初始化
void main() {
  // 设置自定义主题
  AppTheme.instance.setCustomConfig(const MyCustomThemeConfig());

  runApp(const MyApp());
}

/// 响应式布局示例
class ResponsiveLayout extends StatelessWidget {
  const ResponsiveLayout({super.key});

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        if (constraints.maxWidth > 1200) {
          return _buildDesktopLayout();
        } else if (constraints.maxWidth > 800) {
          return _buildTabletLayout();
        } else {
          return _buildMobileLayout();
        }
      },
    );
  }

  Widget _buildDesktopLayout() {
    return Row(
      children: [
        Expanded(
          flex: 2,
          child: _buildSidebar(),
        ),
        Expanded(
          flex: 5,
          child: _buildMainContent(),
        ),
        Expanded(
          flex: 2,
          child: _buildRightPanel(),
        ),
      ],
    );
  }

  Widget _buildTabletLayout() {
    return Column(
      children: [
        _buildHeader(),
        Expanded(
          child: Row(
            children: [
              Expanded(
                flex: 1,
                child: _buildSidebar(),
              ),
              Expanded(
                flex: 2,
                child: _buildMainContent(),
              ),
            ],
          ),
        ),
      ],
    );
  }

  Widget _buildMobileLayout() {
    return Column(
      children: [
        _buildHeader(),
        Expanded(
          child: _buildMainContent(),
        ),
        _buildBottomNavigation(),
      ],
    );
  }

  Widget _buildSidebar() {
    return UIDefaultCard(
      child: Column(
        children: [
          UIButton(
            text: '菜单项 1',
            type: UIButtonType.text,
            fullWidth: true,
            onPressed: () {},
          ),
          UIButton(
            text: '菜单项 2',
            type: UIButtonType.text,
            fullWidth: true,
            onPressed: () {},
          ),
          UIButton(
            text: '菜单项 3',
            type: UIButtonType.text,
            fullWidth: true,
            onPressed: () {},
          ),
        ],
      ),
    );
  }

  Widget _buildMainContent() {
    return UIDefaultCard(
      child: Column(
        children: [
          const Text(
            '主要内容区域',
            style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
          ),
          const SizedBox(height: 16),
          UIProgressIndicator(
            value: 0.7,
            backgroundColor: Colors.grey[300]!,
            valueColor: Colors.blue,
          ),
          const SizedBox(height: 16),
          Wrap(
            spacing: 8,
            children: [
              UITagView(text: '标签 1'),
              UITagView(text: '标签 2', type: UITagType.primary),
              UITagView(text: '标签 3', type: UITagType.success),
            ],
          ),
        ],
      ),
    );
  }

  Widget _buildRightPanel() {
    return UIDefaultCard(
      child: Column(
        children: [
          const Text('右侧面板'),
          const SizedBox(height: 16),
          UIGradientCircularProgressIndicator(
            radius: 30,
            value: 0.8,
            colors: const [Colors.blue, Colors.purple],
          ),
        ],
      ),
    );
  }

  Widget _buildHeader() {
    return Container(
      padding: const EdgeInsets.all(16),
      child: Row(
        children: [
          const Text(
            '应用标题',
            style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
          ),
          const Spacer(),
          UIButton(
            text: '设置',
            type: UIButtonType.outline,
            onPressed: () {},
          ),
        ],
      ),
    );
  }

  Widget _buildBottomNavigation() {
    return Container(
      padding: const EdgeInsets.all(16),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceAround,
        children: [
          UIButton(
            text: '首页',
            type: UIButtonType.text,
            onPressed: () {},
          ),
          UIButton(
            text: '搜索',
            type: UIButtonType.text,
            onPressed: () {},
          ),
          UIButton(
            text: '我的',
            type: UIButtonType.text,
            onPressed: () {},
          ),
        ],
      ),
    );
  }
}

🚀 总结与展望

项目亮点

  1. 完整的组件体系:覆盖了 Flutter 应用开发中的大部分 UI 需求
  2. 灵活的主题系统:支持浅色/深色主题切换,易于定制
  3. 优秀的性能表现:使用 const 构造函数和优化策略
  4. 完善的测试覆盖:确保组件的稳定性和可靠性
  5. 清晰的架构设计:模块化设计,易于维护和扩展

技术特色

  • SOLID 原则:遵循单一职责、开放封闭等设计原则
  • 响应式设计:适配不同屏幕尺寸和设备类型
  • 自定义绘制:部分组件使用 CustomPainter 实现复杂效果
  • 动画优化:使用 AnimationController 实现流畅动画
  • 状态管理:支持多种状态管理方案

未来规划

  1. 组件扩展:继续添加更多实用组件
  2. 主题丰富:提供更多预设主题方案
  3. 国际化支持:完善多语言支持
  4. 性能优化:持续优化组件性能
  5. 文档完善:提供更详细的开发文档

Flutter UI Components 致力于为 Flutter 开发者提供一套完整、高质量、易用的 UI 组件解决方案。通过模块化的架构设计、完善的主题系统和优秀的性能表现,帮助开发者快速构建美观、流畅的 Flutter 应用。

项目由于目前还在完善中,未来会在合适的时候更新出来,感兴趣的同学敬请期待

📚 相关资源


Swift Package Command Plugin 实战:一键生成 Package 元数据

作者 unravel2025
2025年9月9日 18:44

一、Command Plugin 与 Build Tool Plugin 区别速览

维度 Command Plugin Build Tool Plugin
触发方式 手动 swift package plugin xxx或 Xcode UI 自动随 build 触发
沙盒权限 可申请写源码目录、联网 只读源码、不可联网
典型场景 文档、报表、格式化、post-processing 生成代码、资源、修改构建图

二、需求背景:模块化地狱

当 Xcode 工程拆成几十个 Swift Package 后,常遇到:

  • “这个包是谁维护?”
  • “它对外暴露哪些 Product?”
  • “依赖了哪些库?”
  • “代码量有多大?”

手动写 README 极易过期 → 用 Command Plugin 自动生成并回写 README.md。

三、插件能力总览(官方支持)

  1. 读取 context.package 下所有 Manifest 信息(target、product、dependency)
  2. 读文件系统做统计(行数、文件数)
  3. 申请 .writeToPackageDirectory(reason:) 回写 README
  4. 支持 async throws,可调用任意 CLI(git、grep、wc、mermaid-cli …)

四、手把手落地:GeneratePackageMetadata

  1. 创建 Package

可以通过Xcode的菜单进行创建

image.png

image.png

也可以通过命令行创建

mkdir PackageMetadataPlugin && cd $_
swift package init --type library --name SwiftPluginResources

Step 2 新增插件 Target 与产物

// swift-tools-version: 6.2
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription
let packageMetadataPluginName = "GeneratePackageMetadata"

let package = Package(
    name: "SwiftPluginResources",
    platforms: [.macOS(.v10_13)], // 这个插件仅在macOS上使用
    products: [
        .plugin(
            name: packageMetadataPluginName,
            targets: [packageMetadataPluginName]
        ),
    ],
    targets: [
        .plugin(
            name: packageMetadataPluginName,
            capability: .command(
                intent:.custom(
                    // 命令的名字
                    verb: "generate-package-metadata",
                    description: "Auto-generate README with metadata & Mermaid diagrams"
                ),
                permissions: [
                    // 写入package目录权限
                    .writeToPackageDirectory(
                        reason: "The plugin writes/updates the README.md file")
                ])
        ),
    ]
)

Step 3 入口文件(完整补注释版)

// Plugins/GeneratePackageMetadata/GeneratePackageMetadata.swift
import Foundation
import PackagePlugin

@main
struct GeneratePackageMetadata: CommandPlugin {
    func performCommand(context: PluginContext, arguments: [String]) async throws {
        var md = ""
        // 1. 基础信息
        md += try getPackageBaseInfo(context.package)
        // 2. 贡献者列表(git shortlog)
        md += try getContributors(at: context.package.directoryURL)
        // 3. 代码统计
        md += try getStatistics(for: context.package.directoryURL)
        // 4. Product 类图(Mermaid)
        md += try generateProductDiagram(context.package)
        // 5. 依赖关系图
        md += try generateDependencyDiagram(context.package)
        // 6. 写回 README
        try writeReadme(md, to: context.package.directoryURL)
    }
}

// MARK: - 1. 基础信息
private func getPackageBaseInfo(_ pkg: Package) throws -> String {
    // \($0.id) 的地方应该需要展示product的类型
    
    """
    # \(pkg.displayName)
    Generated on \(Date()).
    
    ## Products
    \(pkg.products.map { "- `\($0.name)` (\("$0.id"))" }.joined(separator: "\n"))
    
    ## Targets
    \(pkg.targets.map { "- `\($0.name)`" }.joined(separator: "\n"))
    
    """
}

// MARK: - 2. 贡献者
private func getContributors(at dir: URL) throws -> String {
    let process = Process()
    process.currentDirectoryURL = dir
    process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
    process.arguments = ["shortlog", "-sn", "HEAD"]
    let out = Pipe()
    process.standardOutput = out
    try process.run()
    process.waitUntilExit()
    let data = out.fileHandleForReading.readDataToEndOfFile()
    let gitLog = String(data: data, encoding: .utf8) ?? "N/A"
    return """
    ## Contributors
    ```
    \(gitLog)
    ```
    
    """
}

// MARK: - 3. 代码统计
private func getStatistics(for dir: URL) throws -> String {
    let fm = FileManager.default
    let enumerator = fm.enumerator(atPath: dir.absoluteString)
    var files = 0, lines = 0
    while let file = enumerator?.nextObject() as? String {
        if file.hasSuffix(".swift") {
            files += 1
            let url = URL(fileURLWithPath: dir.absoluteString).appendingPathComponent(file)
            let content = try String(contentsOf: url, encoding: .utf8)
            lines += content.components(separatedBy: .newlines).count
        }
    }
    return """
    ## Statistics
    - Swift files: \(files)
    - Total lines: \(lines)
    
    """
}

// MARK: - 4. Product 类图(Mermaid)
private func generateProductDiagram(_ pkg: Package) throws -> String {
    // prod.type
    var diagram = "```mermaid\nclassDiagram\n"
    for prod in pkg.products {
        diagram += "    class \(prod.name) {\n        <<\("prodType")>>\n    }\n"
    }
    diagram += "```\n\n"
    return diagram
}

// MARK: - 5. 依赖图
private func generateDependencyDiagram(_ pkg: Package) throws -> String {
    var diagram = "```mermaid\ngraph TD\n"
    for dep in pkg.dependencies {
        diagram += "    \(pkg.displayName) --> \(dep.package.displayName)\n"
    }
    diagram += "```\n\n"
    return diagram
}

// MARK: - 6. 写回 README
private func writeReadme(_ content: String, to dir: URL) throws {
    let readmeURL = dir.appendingPathComponent("README.md")
    try content.write(to: readmeURL, atomically: true, encoding: .utf8)
    print("✅ README.md 已生成:\(readmeURL.path)")
}

五、使用方式(客户端工程)

  1. 把插件包当依赖

Xcode中添加本地依赖 image.png

image.png

也可以在Package.swift中添加依赖

// 客户端 Package.swift
dependencies: [
        .package(path: "../SwiftPluginResources")
    ]
  1. 对任意 target 挂上插件(Command 插件不要求跟 target 有编译依赖,挂谁都可以)
.target(
            name: "MyPackage",
            dependencies: [
                .product(
                    name: "GeneratePackageMetadata",
                    package: "SwiftPluginResources")
            ]
        ),
  1. 运行
  • CLI:
swift package generate-package-metadata
  • Xcode 15+:

    Package Dependencies → 右键插件包 → Generate Package Metadata

六、扩展场景清单

  1. 自动生成 CHANGELOG.md(读取 git tag 与 commit message)
  2. 扫描 public API → 输出 SemVer 兼容的 API-Breaking 报告
  3. 结合 Mermaid Live Editor 生成在线可访问的依赖热力图
  4. 把统计结果上传 Notion 数据库,做全局包健康度看板
  5. 在 CI 中先运行插件,再检测 README 是否有未提交的 diff,强制开发者同步文档

七、其他开源command plugin

  1. github.com/MarcoEiding…
  2. github.com/FelixHerrma…
  3. github.com/swiftlang/s…
❌
❌