<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
Swift 早期普遍採用的方案,類似的還有 ObjectMapper
該方案需要使用者手動編寫解碼邏輯,使用成本比較高;目前已被 Swift 官方推出的 Codable 取代
範例:
struct User { let name: String let age: Int } extension User: Unboxable { init(unboxer: Unboxer) throws { self.name = try unboxer.unbox(key: "name") self.age = try unboxer.unbox(key: "age") } }
HandyJSON 目前依賴於從 Swift Runtime 原始碼中推斷的記憶體規則,直接對記憶體進行操作。
在使用方面,不需要繁雜的定義,不需要繼承自 NSObject,宣告實現了協定即可
範例:
class Model: HandyJSON { var userId: String = "" var nickname: String = "" required init() {} } let jsonObject: [String: Any] = [ "userId": "1234", "nickname": "lilei", ] let model = Model.deserialize(from: object)
但是存在相容和安全方面的問題,由於強依賴記憶體佈局規則,Swift 大版本升級時可能會有穩定性問題。同時由於要在執行時通過反射解析資料結構,會對效能有一定影響
Sourcery是一款 Swift 程式碼生成器,使用 SourceKitten 解析 Swift 原始碼,根據 Stencil 模版生成最終程式碼
可客製化能力非常強,基本可以滿足我們所有的需求
範例:
定義了 AutoCodable 協定,並且讓需要被解析的資料型別遵循該協定
protocol AutoCodable: Codable {} class Model: AutoCodable { // sourcery: key = "userID" var userId: String = "" var nickname: String = "" required init(from decoder: Decoder) throws { try autoDecodeModel(from: decoder) } }
之後通過 Sourcery 生成程式碼,這個過程 Sourcery 會掃描所有程式碼,對實現了 AutoCodable 協定的類/結構體自動生成解析程式碼
// AutoCodable.generated.swift // MARK: - Model Codable extension Model { enum CodingKeys: String, CodingKey { case userId = "userID" case nickname } // sourcery:inline:Model.AutoCodable public func autoDecodeModel(from decoder: Decoder) throws { // ... } }
如上所示,還可以通過程式碼註釋(註解)來實現鍵值對映等自定義功能,但是需要對使用者有較強的規範要求。其次在元件化過程中需要對每個元件進行侵入/改造,內部團隊可以通過工具鏈解決,作為跨團隊通用方案可能不是太合適
Swift 4.0 之後官方推出的 JSON 序列化方案,可以理解為 Unbox+Sourcery 的組合,編譯器會根據資料結構定義,自動生成編解碼邏輯,開發者使用特定的 Decoder/Encoder 對資料進行轉化處理。
Codable 作為 Swift 官方推出的方案,使用者可以無成本的接入。不過在具體實踐過程中,碰到了一些問題
Key 值對映不友好,例如以下情況:
// swift struct User: Codable { var name: String var age: Int // ... } // json1 { "name": "lilei" } // json2 { "nickname": "lilei" } // json3 { "nickName": "lilei" }
Swift 編譯器會自動幫我們生成完整的 CodingKeys,但是如果需要將 json 中的 nickname 或 nickName 解析為 User.name 時,需要重寫整個 CodingKeys,包括其他無關屬性如 age
容錯處理能力不足、無法提供預設值
Swift 設計初衷之一就是安全性,所以對於一些型別的強校驗從設計角度是合理的,不過對於實際使用者來說會增加一些使用成本
舉個例子:
enum City: String, Codable { case beijing case shanghai case hangzhou } struct User: Codable { var name: String var city: City? } // json1 { "name": "lilei", "city": "hangzhou" } // json2 { "name": "lilei" } // json3 { "name": "lilei", "city": "shenzhen" } let decoder = JSONDecoder() try { let user = try? decoder.decode(User.self, data: jsonData3) } catch { // json3 格式會進入該分支 print("decode user error") }
上述程式碼中,json1 和 json2 可以正確反序列化成 User 結構,json3 由於 “shenzhen” 無法轉化成 City,導致整個 User 結構解析失敗,而不是 name 解析成功,city 失敗後變成 nil
Swift 5.0 新增的語言特性,通過該方案可以補足原生 Codable 方案一些補足之處,比如支援預設值、自定義解析兜底策略等,具體原理也比較簡單,有興趣的可自行了解
範例:
struct UserPrivilege: Codable { @DefaultFalse var isAdmin: Bool } let json = #"{ "isAdmin": null }"#.data(using: .utf8)! let result = try JSONDecoder().decode(Response.self, from: json) print(result) // UserPrivilege(isAdmin: false)
不過在實際編碼中,需要對資料結構的屬性顯式描述,增加了使用成本
Codable | HandyJSON | BetterCodable | Sourcery | |
---|---|---|---|---|
型別相容 | ❌ | ✅ | ✅ | ✅ |
支援預設值 | ❌ | ✅ | ✅ | ✅ |
鍵值對映 | ❌ | ✅ | ❌ | ✅ |
接入/使用成本 | ✅ | ✅ | ❌ | ❌ |
安全性 | ✅ | ❌ | ✅ | ✅ |
效能 | ✅ | ❌ | ✅ | ✅ |
上述方案都有各自的優缺點,基於此我們希望找到更適合雲音樂的方案。從使用接入和使用成本上來說,Codable 無疑是最佳選擇,關鍵點在於如何解決存在的問題
先看一組資料結構定義,該資料結構遵循 Codable 協定
enum Gender: Int, Codable { case unknown case male case female } struct User: Codable { var name: String var age: Int var gender: Gender }
使用命令 swiftc main.swift -emit-sil | xcrun swift-demangle > main.sil 生成 SIL(Swift Intermediate Language),分析一下編譯器具體做了哪些事情
可以看到編譯器會自動幫我們生成 CodingKeys 列舉和 init(from decoder: Decoder) throws 方法
enum Gender : Int, Decodable & Encodable { case unknown case male case female init?(rawValue: Int) typealias RawValue = Int var rawValue: Int { get } } struct User : Decodable & Encodable { @_hasStorage var name: String { get set } @_hasStorage var age: Int { get set } @_hasStorage var gender: Gender { get set } enum CodingKeys : CodingKey { case name case age case gender @_implements(Equatable, ==(_:_:)) static func __derived_enum_equals(_ a: User.CodingKeys, _ b: User.CodingKeys) -> Bool func hash(into hasher: inout Hasher) init?(stringValue: String) init?(intValue: Int) var hashValue: Int { get } var intValue: Int? { get } var stringValue: String { get } } func encode(to encoder: Encoder) throws init(from decoder: Decoder) throws init(name: String, age: Int, gender: Gender) }
下面摘錄了部分用於解碼的 SIL 片段,不熟悉的讀者可以跳過該部分,直接看後面轉譯過的虛擬碼
// User.init(from:) sil hidden [ossa] @$s6source4UserV4fromACs7Decoder_p_tKcfC : $@convention(method) (@in Decoder, @thin User.Type) -> (@owned User, @error Error) { // %0 "decoder" // users: %83, %60, %8, %5 // %1 "$metatype" bb0(%0 : $*Decoder, %1 : $@thin User.Type): %2 = alloc_box ${ var User }, var, name "self" // user: %3 %3 = mark_uninitialized [rootself] %2 : ${ var User } // users: %84, %61, %4 %4 = project_box %3 : ${ var User }, 0 // users: %59, %52, %36, %23 debug_value %0 : $*Decoder, let, name "decoder", argno 1, implicit, expr op_deref // id: %5 debug_value undef : $Error, var, name "$error", argno 2 // id: %6 %7 = alloc_stack [lexical] $KeyedDecodingContainer<User.CodingKeys>, let, name "container", implicit // users: %58, %57, %48, %80, %79, %33, %74, %73, %20, %69, %68, %12, %64 %8 = open_existential_addr immutable_access %0 : $*Decoder to $*@opened("6CB1A110-E4DA-11EC-8A4C-8A05F3D75FB2") Decoder // users: %12, %12, %11 %9 = metatype $@thin User.CodingKeys.Type %10 = metatype $@thick User.CodingKeys.Type // user: %12 %11 = witness_method $@opened("6CB1A110-E4DA-11EC-8A4C-8A05F3D75FB2") Decoder, #Decoder.container : <Self where Self : Decoder><Key where Key : CodingKey> (Self) -> (Key.Type) throws -> KeyedDecodingContainer<Key>, %8 : $*@opened("6CB1A110-E4DA-11EC-8A4C-8A05F3D75FB2") Decoder : $@convention(witness_method: Decoder) <τ_0_0 where τ_0_0 : Decoder><τ_1_0 where τ_1_0 : CodingKey> (@thick τ_1_0.Type, @in_guaranteed τ_0_0) -> (@out KeyedDecodingContainer<τ_1_0>, @error Error) // type-defs: %8; user: %12 try_apply %11<@opened("6CB1A110-E4DA-11EC-8A4C-8A05F3D75FB2") Decoder, User.CodingKeys>(%7, %10, %8) : $@convention(witness_method: Decoder) <τ_0_0 where τ_0_0 : Decoder><τ_1_0 where τ_1_0 : CodingKey> (@thick τ_1_0.Type, @in_guaranteed τ_0_0) -> (@out KeyedDecodingContainer<τ_1_0>, @error Error), normal bb1, error bb5 // type-defs: %8; id: %12 bb1(%13 : $()): // Preds: bb0 %14 = metatype $@thin String.Type // user: %20 %15 = metatype $@thin User.CodingKeys.Type %16 = enum $User.CodingKeys, #User.CodingKeys.name!enumelt // user: %18 %17 = alloc_stack $User.CodingKeys // users: %22, %20, %67, %18 store %16 to [trivial] %17 : $*User.CodingKeys // id: %18 // function_ref KeyedDecodingContainer.decode(_:forKey:) %19 = function_ref @$ss22KeyedDecodingContainerV6decode_6forKeyS2Sm_xtKF : $@convention(method) <τ_0_0 where τ_0_0 : CodingKey> (@thin String.Type, @in_guaranteed τ_0_0, @in_guaranteed KeyedDecodingContainer<τ_0_0>) -> (@owned String, @error Error) // user: %20 try_apply %19<User.CodingKeys>(%14, %17, %7) : $@convention(method) <τ_0_0 where τ_0_0 : CodingKey> (@thin String.Type, @in_guaranteed τ_0_0, @in_guaranteed KeyedDecodingContainer<τ_0_0>) -> (@owned String, @error Error), normal bb2, error bb6 // id: %20 // %21 // user: %25 bb2(%21 : @owned $String): // Preds: bb1 dealloc_stack %17 : $*User.CodingKeys // id: %22 %23 = begin_access [modify] [unknown] %4 : $*User // users: %26, %24 %24 = struct_element_addr %23 : $*User, #User.name // user: %25 assign %21 to %24 : $*String // id: %25 end_access %23 : $*User // id: %26 ...
大致上就是從 decoder 中獲取 container,在通過 decode 方法解析出具體的值,翻譯成對應的 Swift 程式碼如下:
init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: User.CodingKeys.Type) self.name = try container.decode(String.self, forKey: .name) self.age = try container.decode(Int.self, forKey: .age) self.gender = try container.decode(Gender.self, forKey: .gender) }
由此可見反序列化中關鍵部分就在 Decoder 上,平常使用較多的 JSONDecoder 就是對 Decoder 協定的一種實現
編譯器自動生成的程式碼我們無法人工干預,如果想要讓反序列化結果達到我們的預期,需要客製化化實現一個 Decoder
Swift 標準庫部分是開源的,有興趣的同學可移步 JSONDecoder.swift
public protocol Decoder { var codingPath: [CodingKey] { get } var userInfo: [CodingUserInfoKey : Any] { get } func container<Key>(keyedBy type: Key.Type) throws -> KeyedDecodingContainer<Key> where Key : CodingKey func unkeyedContainer() throws -> UnkeyedDecodingContainer func singleValueContainer() throws -> SingleValueDecodingContainer }
Decoder 包含了 3 種型別的容器,具體關係如下
容器需要實現各自的 decode 方法,進行具體的解析工作
KeyedDecodingContainerProtocol - 鍵值對字典容器協定(KeyedDecodingContainer 用於型別擦除)
func decodeNil(forKey key: Self.Key) throws -> Bool func decode(_ type: Bool.Type, forKey key: Self.Key) throws -> Bool func decode(_ type: String.Type, forKey key: Self.Key) throws -> String ... func decodeIfPresent(_ type: Bool.Type, forKey key: Self.Key) throws -> Bool? func decodeIfPresent(_ type: String.Type, forKey key: Self.Key) throws -> String? ...
SingleValueDecodingContainer - 單值容器協定
func decode(_ type: UInt8.Type) throws -> UInt8 ... func decode<T>(_ type: T.Type) throws -> T where T : Decodable
UnkeyedDecodingContainer - 陣列容器協定
mutating func decodeNil() throws -> Bool mutating func decode(_ type: Int64.Type) throws -> Int64 mutating func decode(_ type: String.Type) throws -> String ... mutating func decodeIfPresent(_ type: Bool.Type) throws -> Bool? mutating func decodeIfPresent(_ type: String.Type) throws -> String?
典型的 JSONDecoder 使用姿勢
let data = ... let decoder = JSONDecoder() let user = try? decoder.decode(User.self, from: data)
解析流程如下:
Decoder 的核心解析邏輯都在 Container 內部,下面會根據我們的需求,對該部分邏輯進行設計與實現
首先需要明確我們最終需要的效果
這裡定義以下幾個協定
預設值協定,預設實現了常見型別的預設值,自定義型別也可以按需實現
public protocol NECodableDefaultValue { static func codableDefaultValue() -> Self } extension Bool: NECodableDefaultValue { public static func codableDefaultValue() -> Self { false } } extension Int: NECodableDefaultValue { public static func codableDefaultValue() -> Self { 0 } } ...
key 值對映協定
public protocol NECodableMapperValue { var mappingKeys: [String] { get } } extension String: NECodableMapperValue { public var mappingKeys: [String] { return [self] } } extension Array: NECodableMapperValue where Element == String { public var mappingKeys: [String] { return self } }
Codable 協定擴充套件
public protocol NECodable: Codable { // key 值對映關係定義,類似 YYModel 功能 static var modelCustomPropertyMapper: [String: NECodableMapperValue]? { get } // 除了 NECodableDefaultValue 返回的預設值,還可以在該函數中定義預設值 static func decodingDefaultValue<CodingKeys: CodingKey>(for key: CodingKeys) -> Any? // 在解析完資料結構之後,提供二次修改的機會 mutating func decodingCustomTransform(from jsonObject: Any, decoder: Decoder) throws -> Bool }
最終的使用姿勢
struct Model: NECodable { var nickName: String var age: Int static var modelCustomPropertyMapper: [String : NECodableMapperValue]? = [ "nickName": ["nickname", "nickName"], "age": "userInfo.age" ] static func decodingDefaultValue<CodingKeys>(for key: CodingKeys) -> Any? where CodingKeys : CodingKey { guard let key = key as? Self.CodingKeys else { return nil } switch key { case .age: // 提供預設年齡 return 18 default: return nil } } } let jsonObject: [String: Any] = [ "nickname": "lilei", "userInfo": [ "age": 123 ], ] let model = try NEJSONDecoder().decode(Model.self, jsonObject: jsonObject) XCTAssert(model.nickName == "lilei") XCTAssert(model.age == 123)
定義類 NEJSONDecoder 作為 Decoder 協定的具體實現,同時還要實現三個容器協定
在容器內部需要實現大量的 decode 方法用於解析具體值,我們可以抽象一個工具類,進行相應的型別解析、轉換、提供預設值等功能
下面給出一部分 keyedContainer 實現,大致流程如下:
首先通過模型定義的 decodingDefaultValue 方法獲取預設值,如果未獲取到進行步驟 b
通過 NECodableDefaultValue 協定獲取型別的預設值
class NEJSONKeyedDecodingContainer<K : CodingKey> : KeyedDecodingContainerProtocol { public func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool { do { return try _decode(type, forKey: key) } catch { if let value = self.defaultValue(for: key), let unbox = try? decoder.unbox(value, as: Bool.self) { return unbox } if self.provideDefaultValue { return Bool.codableDefaultValue() } throw error } } public func _decode(_ type: Bool.Type, forKey key: Key) throws -> Bool { guard let entry = self.entry(for: key) else { throw ... } self.decoder.codingPath.append(key) defer { self.decoder.codingPath.removeLast() } guard let value = try self.decoder.unbox(entry, as: Bool.self) else { throw ... } return value } }
在 NECodable 協定中,保留了 YYModel 的使用習慣,key 對映以及預設值提供需要單獨實現 NECodable 協定的兩個方法
而利用 Swift 的屬性裝飾器,可以讓開發者更加便捷的實現上述功能:
@propertyWrapper class NECodingValue<Value: Codable>: Codable { public convenience init(wrappedValue: Value) { self.init(storageValue: wrappedValue, keys: nil) } public convenience init(wrappedValue: Value, keys: String...) { self.init(storageValue: wrappedValue, keys: keys) } public convenience init<T>(wrappedValue: Optional<T> = .none, keys: String...) where Value == Optional<T> { self.init(storageValue: wrappedValue, keys: []) } public convenience init(keys: String...) { self.init(keys: keys) } // .... } struct Model: NECodable { @NECodingValue(keys: "nickname") var name: String // JSON 中不存在時,預設為 hangzhou @NECodingValue var city: String = "hangzhou" // JSON 中不存在時,預設為 false var enable: Bool }
實現方式比較取巧:
通過屬性修飾器包裝範例變數,NECodingValue(keys: "nickname") 範例最先被初始化,其中包含我們定義的 keys、wrapperValue,而後的 init(from decoder: Decoder) 過程又通過 decoder 生成 NECodingValue(from: decoder) 變數並賦值給 _name 屬性,此時第一個 NECodingValue 變數就會被釋放,從而獲得了一個程式碼執行時機,用來進行客製化的解碼流程(將 defaultValue 複製過來,使用自定義的 key 進行解碼等等…)
反序列化通常用於處理伺服器端返回的資料,基於 Swift 的語法特性,我們可以非常簡單的定義一個網路請求協定,舉個例子:
網路請求協定
protocol APIRequest { associatedtype Model var path: String { get } var parameters: [String: Any]? { get } static func parse(_ data: Any) throws -> Model } // 預設實現 extension APIRequest { var parameters: [String: Any]? { nil } static func parse(_ data: Any) throws -> Model { throw APIError.dataExceptionError() } }
擴充套件 APIRequest 協定,通過 Swift 的型別匹配模式,自動進行反序列化
extension APIRequest where Model: NECodable { static func parse(_ data: Any) throws -> Model { let decoder = NEJSONDecoder() return try decoder.decode(Model.self, jsonObject: data) } }
擴充套件 APIRequest 協定,增加網路請求方法
extension APIRequest { @discardableResult func start(completion: @escaping (Result<Model, APIError>) -> Void) -> APIToken<Self> { // 具體的網路請求流程,基於底層網路庫實現 } }
最終業務側可以非常簡單的定義一個網路介面,並行起請求
// 網路介面定義 struct MainRequest: APIRequest { struct Model: NECodable { struct Item: NECodable { var title: String } var items: [Item] var page: Int } let path = "/api/main" } // 業務側發起網路請求 func doRequest() { MainRequest().start { result in switch result { case .success(let model): // to do something print("page index: (model.page)") case .failure(let error): HUD.show(error: error) } } }
序列化/反序列化過程會存在很多邊界情況,需要針對各場景構造單元測試,確保所有行為符合預期
上圖是各反序列化庫執行 10000 次後得到的結果,可能看到從 Data 資料轉換為 Model 時 JSONDecoder 效能最佳,從 JSON Object 傳換為 Model 時 NEJSONDecoder 效能最佳,HandyJSON 耗時均最長
測試程式碼:
import XCTest @testable import JSONPerformance import NEAutoCodable import HandyJSON class JSONPerformanceTests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. } override func tearDownWithError() throws { // Put teardown code here. This method is called after the invocation of each test method in the class. } func jsonObject() -> [String: Any] { let object: [String: Any] = [ "userId": "12345", "nickname": "使用者1", "avatarImgUrl": "http://baidu.com/avatarImageUrl.jpg", "signature": "qqq", "age": 19, "birthday": 1052209042000, "gender": 2, "constellation": "金牛座", "imAccId": "abcdefgzzzzzzzzz", "realMan": 1, "realPersonGender": 0, "registerTime": 1620289047216, "language": "en-US", ] return object } func jsonData() -> Data { return try! JSONSerialization.data(withJSONObject: jsonObject()) } class Codable_Model: NECodable { var userId: String var nickname: String var avatarImgNosKey: String? var avatarImgUrl: String var signature: String var age: Int var birthday: Int var gender: Int var constellation: String var imAccId: String var realMan: Int var realPersonGender: Int var registerTime: Int var language: String } class NECodable_Model: NECodable { var userId: String var nickname: String var avatarImgNosKey: String? var avatarImgUrl: String var signature: String var age: Int var birthday: Int var gender: Int var constellation: String var imAccId: String var realMan: Int var realPersonGender: Int var registerTime: Int var language: String } class HandyJSON_Model: HandyJSON { var userId: String = "" var nickname: String = "" var avatarImgNosKey: String? var avatarImgUrl: String = "" var signature: String = "" var age: Int = 0 var birthday: Int = 0 var gender: Int = 0 var constellation: String = "" var imAccId: String = "" var realMan: Int = 0 var realPersonGender: Int = 0 var registerTime: Int = 0 var language: String = "" required init() {} } let loopCount = 10000 // 0.128 func testDataJSONDecoder() throws { self.measure { let decoder = JSONDecoder() let data = jsonData() for _ in 0..<loopCount { let model = try! decoder.decode(Codable_Model.self, from: data) } } } // 0.196 func testObjectJSONDecoder() throws { // This is an example of a performance test case. self.measure { let decoder = JSONDecoder() let object = jsonObject() for _ in 0..<loopCount { let data = try! JSONSerialization.data(withJSONObject: object) let model = try! decoder.decode(Codable_Model.self, from: data) } } } // 0.251 func testDataNEJSONDecoder() throws { self.measure { let decoder = NEJSONDecoder() let data = jsonData() for _ in 0..<loopCount { let model = try! decoder.decode(NECodable_Model.self, data: data) } } } // 0.166 func testObjectNEJSONDecoder() throws { self.measure { let decoder = NEJSONDecoder() let object = jsonObject() for _ in 0..<loopCount { let model = try! decoder.decode(NECodable_Model.self, jsonObject: object) } } } // 0.440 func testDataHandyJSON() throws { self.measure { let data = jsonData() for _ in 0..<loopCount { let object = try! JSONSerialization.jsonObject(with: data) as! [String: Any] let model = HandyJSON_Model.deserialize(from: object)! } } } // 0.335 func testObjectHandyJSON() throws { self.measure { let object = jsonObject() for _ in 0..<loopCount { let model = HandyJSON_Model.deserialize(from: object) } } } }
以上就是Swift 中的 JSON 反序列化範例詳解的詳細內容,更多關於Swift JSON反序列的資料請關注it145.com其它相關文章!
相關文章
<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
综合看Anker超能充系列的性价比很高,并且与不仅和iPhone12/苹果<em>Mac</em>Book很配,而且适合多设备充电需求的日常使用或差旅场景,不管是安卓还是Switch同样也能用得上它,希望这次分享能给准备购入充电器的小伙伴们有所
2021-06-01 09:31:42
除了L4WUDU与吴亦凡已经多次共事,成为了明面上的厂牌成员,吴亦凡还曾带领20XXCLUB全队参加2020年的一场音乐节,这也是20XXCLUB首次全员合照,王嗣尧Turbo、陈彦希Regi、<em>Mac</em> Ova Seas、林渝植等人全部出场。然而让
2021-06-01 09:31:34
目前应用IPFS的机构:1 谷歌<em>浏览器</em>支持IPFS分布式协议 2 万维网 (历史档案博物馆)数据库 3 火狐<em>浏览器</em>支持 IPFS分布式协议 4 EOS 等数字货币数据存储 5 美国国会图书馆,历史资料永久保存在 IPFS 6 加
2021-06-01 09:31:24
开拓者的车机是兼容苹果和<em>安卓</em>,虽然我不怎么用,但确实兼顾了我家人的很多需求:副驾的门板还配有解锁开关,有的时候老婆开车,下车的时候偶尔会忘记解锁,我在副驾驶可以自己开门:第二排设计很好,不仅配置了一个很大的
2021-06-01 09:30:48
不仅是<em>安卓</em>手机,苹果手机的降价力度也是前所未有了,iPhone12也“跳水价”了,发布价是6799元,如今已经跌至5308元,降价幅度超过1400元,最新定价确认了。iPhone12是苹果首款5G手机,同时也是全球首款5nm芯片的智能机,它
2021-06-01 09:30:45