IOS 数据存储

IOS 数据存储

1. IOS数据存储方式

1.1 IOS 的沙盒机制

1.1.1 IOS 的沙盒机制简介

  • 什么沙盒?

沙盒其实质就是在iOS系统下,每个应用在内存中所对应的储存空间。

  1. 每个iOS应用都有自己的应用沙盒(文件系统目录),与其他文件系统隔离,
  2. 各个沙盒之间相互独立,而且不能相互访问(手机没有越狱的情况下)。
  3. 各个应用程序的沙盒是相互独立的,在系统内存消耗过高时,系统会收到内存警告并自动将一些退出软件。这就保证了系统的数据的安全性及系统的稳定性。
  • 一个应用对应的沙盒目录:
  1. Documents 应用程序在运行时生成的一些需要长久保存的数据。这个目录用于存储用户数据或其它应该定期备份的信息。

  2. Library/Caches 储存应用程序网络请求的数据信息(音视频与图片等的缓存)。此目录下的数据不会自动删除,需要程序员手动清除该目录下的数据。主要用于保存应用在运行时生成的需要长期使用的数据.一般用于存储体积较大数据。

  3. Library/Preferences 设置应用的一些功能会在该目录中查找相应设置的信息,该目录由系统自动管理,通常用来储存一些基本的应用配置信息,比如账号密码,自动登录等。

  4. tmp 保存应用运行时产生的一些临时数据;应用程序退出、系统空间不够、手机重启等情况下都会自动清除该目录的数据,iTunes或iCloud也不会对其进行备份。无需程序员手动清除该目录中的数据。

  5. SystemData - 系统产生的缓存

1.1.2 IOS 的沙盒路径

 // Document 路径
let documentPaths = NSSearchPathForDirectoriesInDomains(NSSearchPathDirectory.DocumentDirectory,NSSearchPathDomainMask.UserDomainMask, true)
let documnetPath = documentPaths[0] as! String
 // Library 路径
let libraryPaths = NSSearchPathForDirectoriesInDomains(NSSearchPathDirectory.LibraryDirectory,NSSearchPathDomainMask.UserDomainMask, true)
let libraryPath = libraryPaths[0] as! String
 // Cache 路径
let cachePaths = NSSearchPathForDirectoriesInDomains(NSSearchPathDirectory.CachesDirectory,NSSearchPathDomainMask.UserDomainMask, true)
let cachePath = cachePaths[0] as! String
 // tmp 路径
let tmpDir = NSTemporaryDirectory()  

1.1 常见存储方式

  • Plist 格式文件存储
  • NSUserDefaults 沙盒存储(个人偏好存储)
  • 文件读写储存
  • 解归档存储
  • 数据库存储
  • Keychain 存储(可解决设备唯一标识问题)

1.1.1 Plist 格式文件存储

  • Plist文件的特点
  1. plist文件,即属性列表文件。
  2. 可以存储的数据类型有 Array Dictionary String Boolean Date Data Number。
  3. 常用于储存用户的设置 或 存储项目中经常用到又不经常修改的数据。
    4… 创建 .plist 文件可以使用可视化工具即Xcode ,也可以使用代码。
  4. 不适合存储大量数据,而且只能存储基本数据类型。
  5. 虽然可以实现 :增 删 改 查 等操作,但由于数据的存取必须是一次性全部操作,所以性能方面表现并不好。
  • 文件创建
    文件创建
  • 字符串写入
    字符串写入
  • 数组写入
    数组写入
  • 字典写入
    字典写入
  • 数据读取
    数据读取

1.1.2 NSUserDefaults 沙盒存储(个人偏好存储)

  • 一般来说,我们用来保存的偏好设置都是用 NSUserDefaults中,快速简单。

NSUserDefaults存储的特点

  1. 应用程序启动后,会在沙盒路径Library -> Preferences 下默认生成以工程bundle为名字的 .plist 文件 , 该方式存储的数据即存进该文件当中。
  2. 常用语存储用户的个人偏好设置
  3. 这种方式本质是操作plist文件,所以性能方面的考虑同plist文件数据储存。
  • 使用代码
//1、 保存
 NSUserDefaults .standardUserDefaults().setObject("weNeedSaveValue", forKey: "My_Key")
 NSUserDefaults .standardUserDefaults().setBool(false, forKey: "My_IsOK")
//2、同步 
 NSUserDefaults .standardUserDefaults() .synchronize()
 //3、取值
 NSUserDefaults.standardUserDefaults() .objectForKey("My_Key")

1.1.3 文件读写储存

文件读写特点

  1. 文件操作可通过单例 NSFileManager 处理。文件存储的路径可以代码设置。
  2. 可以存储大量数据,对数据格式没有限制。
  3. 但由于数据的存取必须是一次性全部操作,所以在频繁操作数据方面性能欠缺。

1.1.4 解归档存储

  • 归档(又名序列化),把对象转为字节码,以文件的形式存储到磁盘上;程序运行过程中或者当再次重写打开程序的时候,可以通过解归档(反序列化)还原这些对象。

解归档存储的特点:

  1. plist 与 NSUserDefaults(个人偏好设置)两种类型的储存只适用于系统自带的一些常用类型,而且前者必须拿到文件路径,后者也只能储存应用的主要信息。
  2. 对于开发中自定义的数据模型的储存,我们可以考虑使用归档储存方案。
  3. 归档保存数据,文件格式自己可以任意,没有要求 ; 即便设置为常用的数据格式(如:.c .txt .plist 等)要么不能打开,要么打开之后乱码显示。
  4. 值得注意的是使用归档保存的自定义模型需要实现NSCoding协议下的两个方法。
  5. 不适合存储大量数据,可以存储自定义的数据模型。
  6. 虽然归档可以存储自定义的数据结构,但在大批量处理数据时,性能上仍有所欠缺。相对 NSUserDefaults 来说,速度慢些,但是可以保存自定义类型的.

1.1.4.1 归档使用

  1. 首先了解下它所存在的位置上:
var path = NSSearchPathForDirectoriesInDomains(NSSearchPathDirectory.DocumentDirectory, NSSearchPathDomainMask.UserDomainMask, true)[0] as NSString 
var filePath = path.stringByAppendingPathComponent("data.archive")
  1. 先自定义一个数据模型
class MyArchiveModel: NSObject,NSCoding {
    
    var name: String!
    var phone: String!
    var age: NSInteger!
    
    
    func encodeWithCoder(aCoder: NSCoder){
        aCoder.encodeObject(self.name, forKey: "name")
        aCoder.encodeObject(self.phone, forKey: "phone")
        aCoder.encodeObject(self.age, forKey: "age")
    }
    
    required init(coder aDecoder: NSCoder) {
        super.init()
        self.name = aDecoder.decodeObjectForKey("name") as! String
        self.phone = aDecoder.decodeObjectForKey("phone") as! String
        self.age = aDecoder.decodeObjectForKey("age") as! NSInteger
    }
    
    override init() {
        
    }
}
  1. 序列化
//创建一个全局路径,即要保存的位置:
 let path = NSSearchPathForDirectoriesInDomains(NSSearchPathDirectory.DocumentDirectory, NSSearchPathDomainMask.UserDomainMask, true)[0] as NSString
 let filePath = path.stringByAppendingPathComponent("my_Archiver")
    
 let myArchive : MyArchiveModel = MyArchiveModel()
 myArchive.name = "Yang"
 myArchive.phone = "888888"
 myArchive.age = 24
    //归档
 print("\("save")")
 NSKeyedArchiver .archiveRootObject(myArchive, toFile: filePath)
  1. 反序列化
// 之前保存的位置
let path = NSSearchPathForDirectoriesInDomains(NSSearchPathDirectory.DocumentDirectory, NSSearchPathDomainMask.UserDomainMask, true)[0] as NSString

let filePath = path.stringByAppendingPathComponent("my_Archiver")

let unArchive =  NSKeyedUnarchiver .unarchiveObjectWithFile(filePath) as! MyArchiveModel
print("unArchiver===\(unArchive.name,unArchive.phone,unArchive.age)")
// print :unArchiver===("Yang", "888888", 24)

1.1.5 数据库存储

数据库存储一般不直接使用sqlite3,主流使用的DB框架有:

  1. SQLite : 它是一款轻型的嵌入式数据库,安卓和ios开发使用的都是SQLite数据库;占用资源非常的低,在嵌入式设备中,可能只需要几百K的内存就够了;而且它的处理速度比Mysql、PostgreSQL这两款著名的数据库都还快。
  2. FMDB 正式基于 SQLite 开发的一套开源库。使用时,需要自己写一些简单的SQLite语句。
  3. CoreData 是苹果给出的一套基于 SQLite 的数据存储方案;而且不需要自己写任何SQLite语句。该功能依赖于 CoreData.framework 框架,该框架已经很好地将数据库表和字段封装成了对象和属性,表之间的一对多、多对多关系则封装成了对象之间的包含关系。
  4. SQLite.swift :非常好用的Swift封装的框架,轻量级。
  5. WCDB.swift:是一个易用、高效、完整的移动数据库框架,它基于 SQLite 和 SQLCipher 开发。它是腾讯开发的,微信app中使用的框架。

1.1.6 Keychain 存储(可解决设备唯一标识问题)

2. IOS 使用DB存储的方式

  • IOS 目前主流使用DB的方式有:

    1. 直接使用Sqlite3 框架:
    2. 使用FMDB框架:
    3. 使用CoreData:
    4. 使用WCDB.swift框架
    5. 使用SQLite.swift
  • 这里重点推荐使用:WCDB.swift框架。

  1. WCDB OC 使用参考这里:WCDB OC 使用
  2. WCDB Swift使用参考这里:WCDB Swift 使用

2. 1 直接使用Sqlite3 框架

SQLite是一款轻型的嵌入式数据库,它占用资源非常的低,在嵌入式设备中,可能只需要几百K的内存就足够了。它的处理速度比Mysql、PostgreSQL这两款著名的数据库都还快。SQLite提供的是一些C函数接口,你可以用这些函数操作数据库。通过使用这些接口,传递一些标准 sql 语句(以 char * 类型)给 SQLite函数,SQLite就会为你操作数据库
一个数据库中的表就算是一个文件,一般是将这个文件放在沙盒Document目录下,文件后缀名一般为db(database)

  • 缺点

1.使用麻烦:在Swift下使用SQLite很是麻烦(当然OC下也是挺麻烦的),苹果官方文档中,要使用原生的SQLite,要先导入framwork,然后建一个.h文件,还要做一次Swift与C语言的桥接(SQLite是基于C语言的),使用的时候还要写SQL语句.

  • sqlite中支持的数据类型:
数据类型表达式标识类型
smallint短整型
integer整型
real实数型
float单精度浮点
double双精度浮点
currency长整型
varchar字符型
text字符串
binary二进制数据
blob二进制大对象
boolean布尔类型
date日期类型
time时间类型
timestamp时间戳类型
  • 修饰条件
数据类型表达式解释
PRIMARY KEY将本参数这个为主键,主键的值必须唯一,可以作为数据的索引,例如编号。
NOT NULL标记本参数为非空属性。
UNIQUE标记本参数的键值唯一,类似主键。
DEFAULT设置本参数的默认值
CHECK参数检查条件,例如上面代码,写入数据是count必须大于时才有效。

2. 2 使用FMDB框架:

  • 什么是FMDB

FMDB是基于OC语言对SQlite数据库使用的封装,具有对象化概念(注意这里的对象化概念不是说可以直接存储对象,是指OC语言的基本对象【NSInteger(integer)、浮点型(real)、NSString (text) 、 对象或其他(blol)】,实际数据库存储的类型只有小括号内的四中基本的。对于图片等其他类型的可以转化为二进制存储在数据库中。

  • 基本使用
// 删除数据表

if (![database executeUpdate:@"delete from contactLists"]) {
//【learn:以下语句无法删除】drop contactList if exsists
}

//插入
NSString *insertSql = [NSString stringWithFormat:@"insert into contactLists(userName,sign,age) values('%@','%@','%d')",name,@"签名"18];//[注意:这里必须指定值类型需,不可以在values中使用?会出错]

//在executeUpdate后面直接加sql语法时,使用?来表示OC中的对象,integer对应NSNumber,text对应NSString,blob对应NSData,数据内部转换FMDB已经完成,只要sql语法正确就没有问题
    if (![database executeUpdate:@"insert into person (id, name, sex, telephone) values (?, ?, ?, ?)", @4, @"gary", @"male", @"99996666"])





2. 3 使用CoreData

CoreData是苹果在数据存储的亲生儿子,是在Cocoa平台上管理数据层模型和数据持久化的要数据库框架,近年swift的版本更新上基本都会有CoreData相关的更新,说明苹果还是挺重视CoreData的;CoreData为我们提供了可视化的数据表结构,使得我们更加清晰地看到表与表(实体与实体)之间的关系,CoreData底层是基于SQLite数据库的,使用CoreData管理应用程序中的模型层对象。CoreData提供了与对象生命周期和对象图管理相关联的常见任务的通用和自动化解决方案,包括持久性。
CoreData可以使我们更加容易的使用数据库,因为不需要我们编写任何的SQL语句,CoreData底层为我们封装好了模型层的大部分工作,这将有效的提升我们的工作效率,也使得数据库操作更加地面向对象。

  • 缺点:

使用起来比较麻烦,版本迭代中反复修改数据模型、新增数据模型等问题引起的数据库迁移问题给开发工作带来很多不必要的工作;尤其有未解除过该技术的新人加入。

2.3.1 CoreData 结构

CoreData结构图

  • 从上图可以看出,CoreData主要分为两部分,上层是模型层,模型层有NSManagedObjectContext上下文管理着,而底层则是由SQLite实现的持久化部分,通过NSPersistentStore和底层SQL数据库交互,完成存储过程。而这两部分又是由NSPersistentStoreCoordinator持久化协调器关联起来的,上层的存储的数据交由持久化协调器,由协调器指定相关的NSPersistentStore进行相关的数据库存取操作。

2.3.2 CoreData 使用

  1. 新建项目时,勾选上coreData,项目创建成功后,会在AppDelegate类中自动添加相关代码,此外,还会自动生成一个数据模型文件JRCoreData.xcdatamodeld。
  2. 添加实体
  3. 生成同名的类
    1

2

3

4
4. 代码中实现

override func viewDidLoad() {
    super.viewDidLoad()
    
    self .insertTheData()
    
    // 3秒之后获取
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
        Int64(0.5 * Double(NSEC_PER_SEC))), dispatch_get_main_queue()) {
            
            self .findTheData()
            
    }
    
    // Do any additional setup after loading the view, typically from a nib.
}

func insertTheData()
{
    // 获取管理数据的上下文  对象
    let app = UIApplication.sharedApplication().delegate as! AppDelegate
    let context = app.managedObjectContext
    
    let user = NSEntityDescription.insertNewObjectForEntityForName("User", inManagedObjectContext: context) as! User

    user.name = "Yang"
    user.password = "123456"
    user.id = "01"

    do {
        try context .save()

    }
    catch{
        print("\("save failed")")
    }
    
    
    
}

func findTheData()
{
    //获取管理的数据上下文 对象
    let app = UIApplication.sharedApplication().delegate as! AppDelegate
    let context = app.managedObjectContext

    //声明数据的请求
    let fetchRequest:NSFetchRequest = NSFetchRequest()
    fetchRequest.fetchLimit = 10 //限定查询结果的数量
    fetchRequest.fetchOffset = 0 //查询的偏移量
    
    //声明一个实体结构
    let entity:NSEntityDescription? = NSEntityDescription.entityForName("User",
       inManagedObjectContext: context)
   //设置数据请求的实体结构
    fetchRequest.entity = entity
  
    //设置查询条件
    let predicate = NSPredicate(format: "id= '01'", "")
    fetchRequest.predicate = predicate
   
    //查询操作

    var fetchedObjects :[AnyObject]?
    
    do{
       fetchedObjects =  try context .executeFetchRequest(fetchRequest)
    }
    catch{
        print("\("have problem")")
    }
    
  
    
    //遍历查询的结果
    
     for info:User in fetchedObjects as! [User]
     {
        print("id=\(info.id)")
        print("username=\(info.name)")
        print("password=\(info.password)")
     }
    

    //遍历查询的结果
     for info:User in fetchedObjects as! [User]{
        //修改密码
            info.password = "002221"
        //重新保存
        do{
           try context.save()
        }
        catch{
            print("save Failed")
        }
     
      }
    
    
      for info:User in fetchedObjects as! [User]{
            //删除对象
            context.deleteObject(info)
       }
}

2. 4 使用WCDB.swift框架

  • WCDB.swift是腾讯开发的,微信中使用的DB开源框架:

引用官方说法:“WCDB.swift是一个易用、高效、完整的移动数据库框架,它基于 SQLite 和 SQLCipher 开发。”

2. 4.1 使用WCDB.swift框架3大优势

  • 易用性
  1. one line of code 是它坚持的原则,大多数操作只需要一行代码即可完成.
  2. 使用WINQ 语句查询,不用为拼接SQL语句而烦恼了,模型绑定映射也是按照规定模板去实现方便快捷。
  • 高效性
  1. 和fmdb做对比

WCDB.swift和fmdb做对比WCDB.swift和fmdb做对比

  • 完整性
  1. 支持基于SQLCipher 加密
  2. 持全文搜索
  3. 支持反注入,可以避免第三方从输入框注入 SQL,进行预期之外的恶意操作。
  4. 用户不用手动管理数据库字段版本,升级方便自动.
  5. 提供数据库修复工具。

2. 4.2 WCDB.swift安装

  • 安装要求:

Swift 4.0 及以上
Xcode 9.0 及以上

 pod 'WCDB.swift'

2. 4.3 WCDB.swift使用

  • 模型绑定,直接用wcdb提供的模板
class Sample: TableCodable {
    var identifier: Int? = nil
    var description: String? = nil
    
    enum CodingKeys: String, CodingTableKey {
        typealias Root = Sample
        static let objectRelationalMapping = TableBinding(CodingKeys.self)
        case identifier
        case description
    }
}
  • 数据库创建以及操作单独写了个单例类 HMDataBaseManager.swift
import Foundation
import WCDBSwift

struct HMDataBasePath {
    
    let dbPath = NSSearchPathForDirectoriesInDomains(.documentDirectory,
                                                     .userDomainMask,
                                                     true).last! + "/HMDB/HMDB.db"
}

class HMDataBaseManager: NSObject {
    
    static let share = HMDataBaseManager()
    
    let dataBasePath = URL(fileURLWithPath: HMDataBasePath().dbPath)
    var dataBase: Database?
    private override init() {
        super.init()
        dataBase = createDb()
    }
    ///创建db
   private func createDb() -> Database {
        debugPrint("数据库路径==\(dataBasePath.absoluteString)")
        return Database(withFileURL: dataBasePath)
    }
    ///创建表
    func createTable<T: TableDecodable>(table: String, of ttype:T.Type) -> Void {
        do {
            try dataBase?.create(table: table, of:ttype)
        } catch let error {
            debugPrint("create table error \(error.localizedDescription)")
        }
    }
    ///插入
    func insertToDb<T: TableEncodable>(objects: [T] ,intoTable table: String) -> Void {
        do {
            try dataBase?.insert(objects: objects, intoTable: table)
        } catch let error {
            debugPrint(" insert obj error \(error.localizedDescription)")
        }
    }
    
    ///修改
    func updateToDb<T: TableEncodable>(table: String, on propertys:[PropertyConvertible],with object:T,where condition: Condition? = nil) -> Void{
        do {
            try dataBase?.update(table: table, on: propertys, with: object,where: condition)
        } catch let error {
            debugPrint(" update obj error \(error.localizedDescription)")
        }
    }
    
    ///删除
    func deleteFromDb(fromTable: String, where condition: Condition? = nil) -> Void {
        do {
            try dataBase?.delete(fromTable: fromTable, where:condition)
        } catch let error {
            debugPrint("delete error \(error.localizedDescription)")
        }
    }
    
    ///查询
    func qureyFromDb<T: TableDecodable>(fromTable: String, cls cName: T.Type, where condition: Condition? = nil, orderBy orderList:[OrderBy]? = nil) -> [T]? {
        do {
            let allObjects: [T] = try (dataBase?.getObjects(fromTable: fromTable, where:condition, orderBy:orderList))!
            debugPrint("\(allObjects)");
            return allObjects
        } catch let error {
            debugPrint("no data find \(error.localizedDescription)")
        }
        return nil
    }
    
    ///删除数据表
    func dropTable(table: String) -> Void {
        do {
            try dataBase?.drop(table: table)
        } catch let error {
            debugPrint("drop table error \(error)")
        }
    }
    
    /// 删除所有与该数据库相关的文件
    func removeDbFile() -> Void {
        do {
            try dataBase?.close(onClosed: {
                try dataBase?.removeFiles()
            })
        } catch let error {
            debugPrint("not close db \(error)")
        }
    }
}
  • 比较复杂的查询可以使用 prepareSelect 查询接口
//查询所有站点并按字母排序去重
    func qureyAllStations(cityId: Int) -> [StationModel]{
        var stationArray = [StationModel]()
        
        do {
            let selectPrep = try HMDataBaseManager.share.dataBase?.prepareSelect(on: StationModel.Properties.all, fromTable: String(describing: StationModel.self)).where(StationModel.Properties.cityid == cityId).group(by: StationModel.Properties.statid).order(by: StationModel.Properties.statpname.asOrder(by: .ascending))
            stationArray = try selectPrep?.allObjects() ?? []
        } catch let error {
            debugPrint("\(error)")
        }
        
        return stationArray
    }

2. 5 使用SQLite.swift

//: Playground - noun: a place where people can play
//使用SQLite之前记得要添加在工程属性Build Phases 选项,增加SQLite iOS到Target Dependencies中
//用于介绍SQlite.swift的基本使用地址https://github.com/stephencelis/SQLite.swift
//详细使用地址:https://github.com/stephencelis/SQLite.swift/blob/master/Documentation/Index.md#sqliteswift-documentation
 
import SQLite
 
//**链接数据库
let db = try Connection("path/to/db.sqlite3")//与数据库连接(重点)
 
//**定义表属性
let users = Table("users")                   //创建数据表名字
let id = Expression<Int64>("id")            //定义表属性
let name = Expression<String?>("name")
let email = Expression<String>("email")
 
//**建表
try db.run(users.create { t in              //创建表只需要产生一次,多次运行会报错
    t.column(id, primaryKey: true)
    t.column(name)
    t.column(email, unique: true)
    })
// CREATE TABLE "users" (
//     "id" INTEGER PRIMARY KEY NOT NULL,
//     "name" TEXT,
//     "email" TEXT NOT NULL UNIQUE
// )
 
//*插入
let insert = users.insert(name <- "Alice", email <- "alice@mac.com")  //插入的信息
let rowid = try db.run(insert)//返回插入的id                            //执行插入
// INSERT INTO "users" ("name", "email") VALUES ('Alice', 'alice@mac.com')
 
for user in try db.prepare(users) {                       //采用循环式使用(.prepare:表示准备执行某表某操作)//返回表行
    print("id: \(user[id]), name: \(user[name]), email: \(user[email])")
    // id: 1, name: Optional("Alice"), email: alice@mac.com
}
 
 
//*选择
// SELECT * FROM "users"
 
let alice = users.filter(id == rowid) //[filter:过滤]     //查询(返回一个表不是行,无法直接输出)可以像操作数据表一样操作它(并且会同步到初始表)如果失败了,表的数量为0呗(db.scalar(alice.count) //0)
 
 
//*更新
try db.run(alice.update(email <- email.replace("mac.com", with: "me.com")))//执行更新
// UPDATE "users" SET "email" = replace("email", 'mac.com', 'me.com') //这里还是部分更新
// WHERE ("id" = 1)
 
 
 
 
//*删除
try db.run(alice.delete())                                           //执行删除
// DELETE FROM "users" WHERE ("id" = 1)
 
db.scalar(users.count) // 0    //【scalar:数量】                      //统计数据行数
// SELECT count(*) FROM "users"
 
 建议,实际使用,将部分代码(如建表)分开到另外的地方,将一些其他的数据变量化
 
 /// SQLite.swift also works as a lightweight, Swift-friendly wrapper over the C API.
 
//可以直接执行语句????
let stmt = try db.prepare("INSERT INTO users (email) VALUES (?)")
for email in ["betty@icloud.com", "cathy@icloud.com"] {
    try stmt.run(email)
}
 
db.totalChanges    // 3
db.changes         // 1
db.lastInsertRowid // 3
 
for row in try db.prepare("SELECT id, email FROM users") {
    print("id: \(row[0]), email: \(row[1])")//用下标代替列
    // id: Optional(2), email: Optional("betty@icloud.com")
    // id: Optional(3), email: Optional("cathy@icloud.com")
}
 
db.scalar("SELECT count(*) FROM users") // 2


3. Realm、WCDB与SQLite移动数据库性能对比测试


版权声明:本文为kyl282889543原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。