SwiftUI 跨组件数据传递

发表于 3年以前  | 总阅读数:315 次

0x00 前言

众所周知,SwiftUI 的开发模式与 React、Flutter 非常相似,即都是声明式 UI,由数据驱动(产生)视图,视图也会与数据自动保持同步,框架层会帮你处理“绑定”的问题。

在声明式 UI 中不存在命令式地让一个视图变成 xxx 样子的方法,所有视图的属性都必须映射到一个状态上,那么这就涉及到一个问题:数据该如何传递。父子组件可以直接通过 StateBinding 来传递数据,就像这样:

struct RootView: View {
    @State var input: String = ""
    var body: some View {
        VStack {
            TextField("Email", text: $input)  // $input: Binding<String>
            Text("Your input is \"\(input)\"")
        }
    }
}

这里这个 input 被称为 “Source of Truth”$input 则用于获取其对应的 Binding,以便让子视图可以修改这同一个值。Binding 可以让我们轻松实现父子组件的通信,而不需像在 React 里那样手动传递 callback 来修改父组件中的状态。

更多 React 与 SwiftUI 的对比可以参考我之前写过的一个文章:https://github.com/unixzii/swiftui-for-react-devs[1]

0x10 数据传递的问题

简单的父子组件通信不能满足一般 apps 的需求,因为有时几个层级很深的视图可能要同时访问某个全局的状态,并且保持同步。使用属性逐层传递这个 “Source of Truth” 可以达到我们的目的但十分麻烦:

这也就是为什么 React 会提供 Context[2] 机制(Flutter 中则是 [InheritedWidget](https://api.flutter.dev/flutter/widgets/InheritedWidget-class.html "InheritedWidget") 部件),它们会极大简化状态的传递过程:

父视图只需要用特定的 “ID” 标识这个状态,其所有子树视图便可以通过相同的 “ID” 获取到这个状态,省去了显式传值的麻烦。

0x20 被低估的 Environment

SwiftUI 也提供了这样的便利,即 Environment。通常我们会创建一个对象让其实现 ObservableObject 协议,然后这样将其 attach 到某个视图:

class MyContext: ObservableObject { /* ... */ }

// In the view that provides the context:
var body: some View {
    Group {
        // ...
    }
    .environmentObject(MyContext())
}

然后在任何一个子视图中就可以使用 @EnvironmentObject 来获取这个对象了,并且由于对象实现了 ObservableObject,当其发生变化时所有依赖视图也会进行更新。

实际上 SwiftUI 中大量使用了 Environment 来实现视图的常规特性,如 disabled()redacted(reason:) 这些 modifiers 都是通过 Environment 实现的,这也就是为什么当父视图设置了 redacted 之后,整个子视图都会变成 placeholder。

由于 SwiftUI 中很多 modifier extension 方法都是 inlinable 的,我们就可以在 SwiftUI 的 swiftinterface 文件 (位于 /path/to/Your-Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/System/Library/Frameworks/SwiftUI.framework/Modules/SwiftUI.swiftmodule/arm64-apple-ios.swiftinterface) 中看到其实现,下面是 disabled() 的实现:

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension View {
  @inlinable public func disabled(_ disabled: Swift.Bool) -> some SwiftUI.View {
        return modifier(_EnvironmentKeyTransformModifier(
            keyPath: \.isEnabled, transform: { $0 = $0 && !disabled }))
    }

}

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen public struct _EnvironmentKeyTransformModifier<Value> : SwiftUI.ViewModifier, SwiftUI._GraphInputsModifier {
  public var keyPath: Swift.WritableKeyPath<SwiftUI.EnvironmentValues, Value>
  public var transform: (inout Value) -> Swift.Void
  @inlinable public init(keyPath: Swift.WritableKeyPath<SwiftUI.EnvironmentValues, Value>, transform: @escaping (inout Value) -> Swift.Void) {
        self.keyPath = keyPath
        self.transform = transform
    }
  public static func _makeInputs(modifier: SwiftUI._GraphValue<SwiftUI._EnvironmentKeyTransformModifier<Value>>, inputs: inout SwiftUI._GraphInputs)
  public typealias Body = Swift.Never
}

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension View {
  @inlinable public func transformEnvironment<V>(_ keyPath: Swift.WritableKeyPath<SwiftUI.EnvironmentValues, V>, transform: @escaping (inout V) -> Swift.Void) -> some SwiftUI.View {
        return modifier(_EnvironmentKeyTransformModifier(
            keyPath: keyPath, transform: transform))
    }

}

可以看到 disabled() 本质就是应用了一个 modifier,而这个 modifier 正是 transformEnvironment(:transform:) 的实现。

0x21 environment(_:_:)

这里又引出了 environment(_:_:)transformEnvironment(_:transform:) 两个 modifiers 了,它是 Environment 机制的核心。我们来看它们的第一个参数,是 EnvironmentValues 的 key path,这个就是我们所说的 “ID” 了,它可以标识一个要广播的 context,子视图使用相同的 key path 构造 @Environment 即可获取到对应的值。

要使用 environment(_:_:) 来声明 context 需要我们声明新的 EnvironmentKey 并扩展 EnvironmentValues,例如:

private struct EnabledEnvironmentKey: EnvironmentKey {
    static let defaultValue: Bool = true
}

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension EnvironmentValues {
  public var isEnabled: Swift.Bool {
    get { self[EnabledEnvironmentKey.self] }
    set { self[EnabledEnvironmentKey.self] = newValue }
  }
}

这些做完之后我们就可以这样来应用它们了:

MyView()
    .environment(\.isEnabled, false)

0x22 transformEnvironment(_:transform:)

但是现在有个问题,如果我的某个中间子视图也设置了 environment 怎么办?最内层的视图拿到的值是什么呢?例如下面的代码:

struct InnermostView: View {
    @Environment(\.isEnabled) var isEnabled
    var body: some View {
        Text("\(isEnabled ? 1 : 0)")
    }
}

struct RootView: View {
    var body: some View {
        ZStack {
            ZStack {
                InnermostView()
            }
            .environment(\.isEnabled, true)
        }
        .environment(\.isEnabled, false)
    }
}

我们会发现最后的输出是 1,这说明内层的值覆盖了外层的值。但这往往不是我们希望的,我们希望父视图的 isEnabledfalse 时能强制禁用掉所有子视图,这也符合其定义。这就需要引出 transformEnvironment(_:transform:) 了,我们这样修改代码:

struct RootView: View {
    var body: some View {
        ZStack {
            ZStack {
                InnermostView()
            }
            .transformEnvironment(\.isEnabled) { $0 = $0 && true }
        }
        .transformEnvironment(\.isEnabled) { $0 = $0 && false }
    }
}

transformEnvironment(_:transform:) 的第二个参数是一个闭包,它的参数为上一层 environment 的值,在这个闭包中我们可以基于上一层的值计算出一个新的值。这样我们每层做 $0 = $0 && value 的操作就相当于最终的 isEnabled 是所有层的值与起来的结果。我们再次运行,输出 0,现在就符合预期了。

到这里可以回顾下上文给出的 disabled() modifier 实现,是不是恍然大悟呢。当你创建自定义视图时,其实并不需要把所有属性列在构造器中,巧妙地使用 Environment 会让你的视图 API 更简洁,同时也易于与其运行的环境融合,毕竟你肯定不想自己造 isEnabled 这样的轮子,是吧。

最后悄悄再看一下 environmentObject() 的实现:

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension View {
  @inlinable public func environmentObject<T>(_ object: T) -> some SwiftUI.View where T : Combine.ObservableObject {
        return environment(T.environmentStore, object)
    }

}

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension ObservableObject {
  @usableFromInline
  internal static var environmentStore: Swift.WritableKeyPath<SwiftUI.EnvironmentValues, Self?> {
    get
  }
}

其实也是 Environment 的变种应用。

到这里 Environment 这个 part 就结束了,请记住这个 transform 的概念,因为下面我们会继续用到。

0x30 “不为人知”的 Preference

说到 Preference,大家可能会一脸懵,这玩意跟 UserDefaults 是啥关系?答案是:**没关系。半毛钱关系都没!**让我们先来看看文档是怎么说的:

听懂掌声。

... 所以我们还是用一个实际的例子来讲讲它的用处吧,先看下面这张图:

这是一个很常见的交互,选中的 tab 下方会有一个红点,这个红点居中于 item 下方。你可能会说这很简单呀,用 overlay() 就轻松搞定了,但我如果让你加入切换滑动动画呢。整个视图里小红点必须位于同级才可以做滑动动画,所以我给出该视图的一个大致框架:

var body: some View {
        HStack(alignment: .center, spacing: 16) {
            ForEach(self.data, id: \.self) { element in
                self.content(element)
            }
        }
        .overlay(
            RoundedRectangle(cornerRadius: 3)
                .fill(Color.red)
                .frame(width: 6, height: 6)
                .position(???)
                .offset(x: 0, y: ???)
        )
    }

通过这个结构,我们可以使用动画来修改小红点的 position 和 offset,但是它们的值怎么计算呢?这个问题其实也等同于如何获取选中 item 视图的 frame。

我们知道 GeometryReader 可以实现相关的操作,结合 coordinateSpace(name:)frame(in:) items 就可以知道自己在父视图里的位置了:

private let coordinateSpaceName = UUID().uuidString

var body: some View {
        HStack(alignment: .center, spacing: 16) {
            ForEach(self.data, id: \.self) { element in
                self.content(element)
                    .background(
                        GeometryReader() { (innerGeometry) -> AnyView in
                            let frame = innerGeometry.frame(in: .named(self.coordinateSpaceName))
                            // Then?
                        }
                    )
            }
        }
        .coordinateSpace(name: self.coordinateSpaceName)
        .overlay(
            RoundedRectangle(cornerRadius: 3)
                .fill(Color.red)
                .frame(width: 6, height: 6)
                .position(???)
                .offset(x: 0, y: ???)
        )
    }

现在我们可以拿到 frame 了,但是如何让父视图得到这个值呢?

我们先来看看 Flutter 是如何解决的,在 Flutter 中如果我们想让一个视图撑满一个 Row 或者 Column,可以将视图包裹在 Expanded 中:

看下 Expanded 的实现可以发现其是一个 ParentDataWidget[3],它也是用于建立父子组件通信的,只不过是子到父的单向通信,即子视图暴露一些父视图需要的属性,如自身大小、是否需要填满等等。它的整个工作流程大致如下:

这套机制能完美解决我们的问题,而 SwiftUI 也提供了这项能力,它就叫 Preference!与 Environment 类似,使用前需要声明 PreferenceKey。按照我们的需求,我们需要收集所有 item 视图的 frame,所以可以这样实现:

struct KeyedItemPreferenceKey<Key: Hashable>: PreferenceKey {
        typealias Value = [Key: CGRect]
        static var defaultValue: Value { return [:] }
        static func reduce(value: inout Value, nextValue: () -> Value) {
            for (index, center) in nextValue() {
                value[index] = center
            }
        }
    }

这个 key 表明该属性的类型是一个字典(以 item index 为下标存储所有 item 的 frame),然后默认值是空字典。接下来有个比较重要的方法,reduce(value:nextValue:),它用于组合所有同 key 的值。因为我们一个视图下所有的子视图都有一个 preference 值(即 [index: frame]),而同个 key 只会有一个值,所以要按一定逻辑进行收敛。我们这里就简单将所有字典进行 merge,将它们变成一个字典给到父视图。

接下来我们开始接收子视图传来的 preference:

@State
private var itemPreferences = [Data.Index: CGRect]( "Data.Index: CGRect")

var body: some View {
        HStack(alignment: .center, spacing: 16) {
            ForEach(self.data, id: \.self) { element in
                self.content(element)
                    .background(
                        GeometryReader() { (innerGeometry) -> AnyView in
                            let index = self.data.firstIndex(of: element)!
                            let frame = innerGeometry.frame(in: .named(self.coordinateSpaceName))
                            return AnyView(GeometryReader() { _ in EmptyView() }
                                .preference(key: KeyedItemPreferenceKey<Data.Index>.self,
                                            value: [index: frame]))
                        }
                    )
            }
        }
        .coordinateSpace(name: self.coordinateSpaceName)
        .onPreferenceChange(KeyedItemPreferenceKey<Data.Index>.self) {
            self.itemPreferences = $0
        }
        .overlay(
            RoundedRectangle(cornerRadius: 3)
                .fill(Color.red)
                .frame(width: 6, height: 6)
                .position(preferencesOfSelectedItem.center)
                .offset(x: 0, y: preferencesOfSelectedItem.height / 2 + 8)
        )
    }

使用 onPreferenceChange(:perform:) 来响应 preference 的变化,这里只需将其存储到 State 中即可。

到这里我们就实现了我们想要的效果。但其实目前的实现有点违反 Single Source of Truth 的理念,我们记录的 frame 和视图真实的 frame 是两份,不过机制上能保证同步罢了。有没有什么方法可以不用额外存储呢?答案是肯定的,SwiftUI 提供了 overlayPreferenceValue(::)backgroundPreferenceValue(::) 两个 API 可以让我们用 preference 的值来构造 overlay 和 background。我们可以用它把代码再优化一下:

// @State
// private var itemPreferences = [Data.Index: CGRect]( "Data.Index: CGRect")

var body: some View {
        HStack(alignment: .center, spacing: 16) {
            // ... Same
        }
        .coordinateSpace(name: self.coordinateSpaceName)
//        .onPreferenceChange(KeyedItemPreferenceKey<Data.Index>.self) {
//            self.itemPreferences = $0
//        }
        .overlayPreferenceValue(KeyedItemPreferenceKey<Data.Index>.self) {
            let preferencesOfSelectedItem = $0[selectedIndex]!
            RoundedRectangle(cornerRadius: 3)
                .fill(Color.red)
                .frame(width: 6, height: 6)
                .position(preferencesOfSelectedItem.center)
                .offset(x: 0, y: preferencesOfSelectedItem.height / 2 + 8)
        }
    }

运行一下可以发现效果是一模一样的。

例子说完了,我们这里也可以思考一下系统提供的哪些 API 是使用了 Preference 机制。简单调试后我发现 navigationTitle() 这类 modifiers 是 Preference 实现的,虽然不是内联方法,没有体现在 swiftinterface 中,但是打印被它修饰的 view 的类型可以看到:

NavigationView<
  ModifiedContent<
    Text,
    TransactionalPreferenceTransformModifier<NavigationTitleKey>
  >
>

transformPreference(::)preference(::) 的闭包变换版本)的实现是:

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen public struct _PreferenceTransformModifier<Key> : SwiftUI.ViewModifier where Key : SwiftUI.PreferenceKey {
  public var transform: (inout Key.Value) -> Swift.Void
  public typealias Body = Swift.Never
  @inlinable public init(key _: Key.Type = Key.self, transform: @escaping (inout Key.Value) -> Swift.Void) {
        self.transform = transform
    }
  public static func _makeView(modifier: SwiftUI._GraphValue<SwiftUI._PreferenceTransformModifier<Key>>, inputs: SwiftUI._ViewInputs, body: @escaping (SwiftUI._Graph, SwiftUI._ViewInputs) -> SwiftUI._ViewOutputs) -> SwiftUI._ViewOutputs
}

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension View {
  @inlinable public func transformPreference<K>(_ key: K.Type = K.self, _ callback: @escaping (inout K.Value) -> Swift.Void) -> some SwiftUI.View where K : SwiftUI.PreferenceKey {
        return modifier(_PreferenceTransformModifier<K>(transform: callback))
    }

}

因此可以断定 navigationTitle()transformPreference(::) 的变种实现的。

这种子视图向上管理的模式其实在前端里也比较常见,例如 React 中声明式设置文档标题的库 react-document-title[4] 也使用了一个类似的实现 react-side-effect[5]。

0x40 总结

最后我们来总结一下

  • Environment:指父视图给其下面所有子视图提供的环境,通常业务数据需要采用这种方式进行传递,从而使代码更加容易维护。React、Flutter 均提供了类似的机制,较为常用。
  • Preference:指子视图声明的偏好,父视图可以获取到子视图的偏好,从而可以调整自己的一些行为。可以用于实现比较复杂的布局,因为父视图可能需要根据子视图的大小位置决定布局行为。Flutter 中提供了类似的机制,但不支持跨层传递。

此外,两者均支持 transform,从而自定义多层之间的覆盖行为。

两个组成 SwiftUI Data Flow 的重要成员,本文就暂时介绍到这里。通过文中两个 API 的对比,大家也一定能看出 SwiftUI 中 API 设计的一些套路,希望会给大家以后使用 SwiftUI 提供新的思路。

本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/KtsMLF0aHhMxgTz8RxNnLA

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  237231次阅读
vscode超好用的代码书签插件Bookmarks 2年以前  |  8065次阅读
 目录