您的位置:首页 > 移动开发 > Swift

Swift黑科技:还在争论MVC和MVVM?博主独创幽灵架构MV!

2016-04-11 00:54 519 查看
本人原创,长文慎入,但此文可能会改写你的编程风格。我认为数据和模型交互的关键问题是如何处理数据源和视图源本身的异构性。通过面向协议编程的不断实践,总结他人的理论经验,我发现了使用两个极简的通用协议可以完美破解异构的问题,也就是本文想要介绍的MV架构。在最初版的版本中我想把这个架构命名为MVP(Model-View-Protocol),因为本文浏览的人比较多,这个命名容易和现有的MVP(Model-View-Presenter)造成混淆,但其实二者有着天壤之别,由于在本架构中Protocol部分的语法非常简短精炼,数据的处理分发给了Model和View本身,所以我觉得P可以去掉了,这个架构就叫MV,如幽灵般鬼魅。下面是原文内容:

WWDC2015已经过去一段时间了,我发现自从更新了Swift2.0到现在的Swift2.2,我只是跟着版本更新了所有需要更新的语法,依旧自以为是很熟练的Swift程序员。刚入职比较闲碰巧看到了1月份的中国首届Swift大会上大牛们的分享,突然陷入了思考,有了很多新想法又重温了几遍WWDC2015大会的视频,尤其是408和414号视频!!!我下定决心重构自己的代码,下面步入正题,结合Swift开发大会的一些分享,让我们谈谈架构。

通过一个简单的Demo:一个事件提醒的小应用。

这个应用会使用一个TableView混合展示一个时间段的所有待办事宜和这个时间段的节日提醒,由于待办事件和节日的数据构成是不同的,所以需要创建两个模型,它们在TableView上展示的样式也应该有所不同,很常规的我们还需要一个TableViewCell的子类。

现在数据工程里面的目录是这样的:



模型代码:

struct Event {
var date = ""
var eventTitle = ""
init(date:String,title:String){
self.date = date
self.eventTitle = title
}
}

struct Festival {
var date = ""
var festivalName = ""
init(date:String,name:String){
self.date = date
self.festivalName = name
}
}


为了简单我都使用了String类型的数据,至于为什么要使用struct而不使用class,大家可以参考WWDC2015的414号视频,讲的非常清楚,我自己的项目中的数据模型已经全部转成struct了,我会在后面专门写博文讲解struct,这里就不赘述了。这里需要啰嗦一下,注意创建的时候使用的是字面量的方法,而不是可选型,我一直认为使用字面量的方法是更好的选择,可选型很容易被当做语法糖滥用。尤其是数据的初始化中,你确定你真的需要一个空值?拿一个空值能做什么?做某种标志位么?请和你的后台开发人员商议,让他给你一个Bool类型的标志位,而不是一个空值。在可能的情况下,给你的数据模型的属性赋一个语义明确的字面量初始值,比如这里我们使用空字符串作为初始值。如果你的数据只是做展示的不会存在修改情况,你也可以使用如下的方法做初始化,以达到效率的最大化:

struct Event {
let date:String
let eventTitle:String
init(date:String = "",eventTitle:String = ""){
self.date = date
self.eventTitle = eventTitle
}
}


在Swift1.2版本之后,let定义的数据也支持延迟加载了,这里使用了默认参数值做非空的保障。

模型否则在创建一个实例的时候各种可选型的解包或可选绑定会让你吃尽苦头,空值的访问是程序carsh的元凶!

如果如果你更新了Xcode7.3,你会发现在创建一个属性的时候Xcode的提示是“ =“,没错,Xcode推荐你用字面量去做初始化。

有了数据模型后,在Cell上创建两个Label

class ShowedTableViewCell: UITableViewCell {
//用来展示事件主题或节日名称的Label
@IBOutlet weak var MixLabel: UILabel!
//用来展示日期的Label
@IBOutlet weak var dateLabel: UILabel!

}


MVC架构:

从这里我们将展示传统的MVC的写法,但是包含了一些关键的知识点,所以还是建议您不要跳过。我们通过控制器中的代码去控制数据的展示,由于数据源包含两种数据类型,可以构造两个数组避免数组的异构:

var eventList = [Event]()
var festivalList = [Festival]()
let loadedEventList = [Event(date: "2月14", eventTitle: "送礼物")]
let loadedFestivalList = [Festival(date: "1月1日", festivalName: "元旦"),Festival(date: "2月14", festivalName: "情人节")]


这里使用了struct的默认构造器构造对象,有两个节日提醒:元旦节和情人节,元旦节没什么事情做,情人节那天有个事件提醒”送礼物“,我们使用GCD去模拟数据刷新,整个控制器的代码如下:

import UIKit

let cellReusedID = "ShowedTableViewCell"
class ShowedTableViewController: UITableViewController {

var eventList = [Event]()
var festivalList = [Festival]()
let loadedEventList = [Event(date: "2月14", eventTitle: "送礼物")]
let loadedFestivalList = [Festival(date: "1月1日", festivalName: "元旦"),Festival(date: "2月14", festivalName: "情人节")]
override func viewDidLoad() {
super.viewDidLoad()
let delayInSeconds = 2.0
let popTime = dispatch_time(DISPATCH_TIME_NOW,
Int64(delayInSeconds * Double(NSEC_PER_SEC)))
dispatch_after(popTime, dispatch_get_main_queue()) { () -> Void in
self.eventList = self.loadedEventList
self.festivalList = self.loadedFestivalList
self.tableView.reloadData()
}
}

// MARK: - Table view data source

override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
// #warning Incomplete implementation, return the number of sections
return 1
}

override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
// #warning Incomplete implementation, return the number of rows
return eventList.count + festivalList.count
}

override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier(cellReusedID, forIndexPath: indexPath) as! ShowedTableViewCell
//传统的MVC,你需要在这里处理数据本身的同构与异构情况,还得处理数据与视图的逻辑关系
//这里我们把事件提醒放在节日的前面展示
if indexPath.row > eventList.count - 1{
cell.MixLabel.text = festivalList[indexPath.row - eventList.count].festivalName
cell.dateLabel.text = festivalList[indexPath.row - eventList.count].date
cell.backgroundColor = UIColor.whiteColor()
return cell
} else {
cell.MixLabel.text = eventList[indexPath.row].eventTitle
cell.dateLabel.text = eventList[indexPath.row].date
cell.backgroundColor = UIColor.redColor()
return cell
}
}

}


运行一下看看:



似乎还不错,我们把两个不同的数据结构展现在一张页面上了,并且复用了cell,但是设置cell的代理方法中的代码似乎有点多,而且如果我需要按照时间去排序,那么两个同构的数组作为数据源不好排序,那么重构首先从把同构变成异构开始。由于struct没有继承,按照Swift2.0的精神,此时我们需要提炼两个数据模型的共性,方法是利用protocol,观察到Event和Festival都有date属性,所以写一个协议:

protocol HasDate{
var date:String {get}
}


这里这个协议只有一个属性date,Swift协议中定义的属性只有声明,遵守协议的对象必须实现这个属性,但是不限于存储属性还是计算属性。协议中定义的属性必须指定最低的访问级别,这里的date必须是可读的,至于可写的权限取决于实现该协议的数据类型中的定义。由于我们的Event和Festival都具有了date属性,直接让二者遵守HasDate协议,不要用扩展的方式让二者遵守协议,编译器报错的,很怪0 0.

修改并化简控制器中的数据源,使用异构数据源,现在控制器的代码如下:

import UIKit

let cellReusedID = "ShowedTableViewCell"
class ShowedTableViewController: UITableViewController {

var dataList = [HasDate]()
var loadeddataList:[HasDate] = [Event(date: "2月14", eventTitle: "送礼物"),Festival(date: "1月1日", festivalName: "元旦"),Festival(date: "2月14", festivalName: "情人节")]
override func viewDidLoad() {
super.viewDidLoad()
let delayInSeconds = 2.0
let popTime = dispatch_time(DISPATCH_TIME_NOW,
Int64(delayInSeconds * Double(NSEC_PER_SEC)))
dispatch_after(popTime, dispatch_get_main_queue()) { () -> Void in
//注意这里,我故意把loadeddataList中的数据打乱了,为了实现异构数据的按照某个公共类型的属性的排序,使用了Swift内置的sort函数,String遵守了Compareable协议,这里为了简单吧date指定为String类型,如果是NSDate,你可以在sort的闭包中指定合适的排序规则。
self.dataList = self.loadeddataList.sort{$0.date < $1.date}
self.tableView.reloadData()
}
}

// MARK: - Table view data source

override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
return 1
}

override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return dataList.count
}

override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier(cellReusedID, forIndexPath: indexPath) as! ShowedTableViewCell
//注意这里,通过可选绑定进行异构数据的类型控制
if let event = dataList[indexPath.row] as? Event{
cell.MixLabel.text = event.eventTitle
cell.dateLabel.text = event.date
cell.backgroundColor = UIColor.redColor()
return cell
} else if let festival = dataList[indexPath.row] as? Festival{
cell.MixLabel.text = festival.festivalName
cell.dateLabel.text = festival.date
cell.backgroundColor = UIColor.whiteColor()
return cell
} else {
return cell
}
}
}


运行一下:



没有任何问题。对异构数组的类型判断的写法来自于WWDC2015上的408号视频,现在控制器里的代码已经精简了很多了,我们解决了异构的问题,对于MVC来说,这似乎已经精简到极限了。这是一个简单的Demo,在真正的工程中一个控制器当中的代码可能有几百上千行,或者有多个TableView,这个时候MVC的弊端就显现了,在几百行代码中可能有一百行都用来做数据与视图的绑定,而数据模型和视图本身的代码定义中却只有寥寥数十行,控制器的负担太重了!因此有人提出了将控制器中有关模型与视图的逻辑的代码提出到一个单独的区域进行处理,这就是MVVM架构的由来。

MVVM架构

对MVVM架构的解读我想引用Swift开发者大会上李信洁前辈的示例写法,通过POP来实现一个MVVM,并且对其写法进行了一些精简。我们先不修改View和Modal的代码,因为需要更新的是一个cell,所以首先需要写一个传递Modal中数据的协议:

protocol CellPresentable{
var mixLabelData:String {get set}
var dateLabelData:String {get set}
var color: UIColor {get set}
func updateCell(cell:ShowedTableViewCell)
}


这个协议的思想是显示地声明一个更新cell的方法,并根据cell需要的数据声明两个属性,我们并不关心mixLabel和dateLabel的数据从哪里来,叫什么名字,但他们的功能是确定的,Swift2.0之后可以扩展协议,下面通过协议扩展给这个协议增加默认的实现,这样在绑定数据时可以减少代码量:

extension CellPresentable{
func updateCell(cell:ShowedTableViewCell){
cell.MixLabel.text = mixLabelData
cell.dateLabel.text = dateLabelData
cell.backgroundColor = color
}
}


好了,我们写好了,下一步我们要修改cell的代码,增加一个方法接受一个CellPresentable:

class ShowedTableViewCell: UITableViewCell {
//用来展示事件主题或节日名称的Label
@IBOutlet weak var MixLabel: UILabel!
//用来展示日期的Label
@IBOutlet weak var dateLabel: UILabel!

func updateWithPresenter(presenter: CellPresentable) {
presenter.updateCell(self)
}
}


这里也做了一些改进,李信洁前辈的示例中是针对每一个控件去定义方法的,其实对一个View的所有IBOutlet做更新不就是更新它自己么,所以这里我的写法是直接传入self。然后(我也不想多说然后,但是步骤就是这么多)为了绑定异构的Model和View你还需要定义一个ViewModel,并且通过定义不同的init实现数据绑定:

struct ViewModel:CellPresentable{
var dateLabelData = ""
var mixLabelData = ""
var color = UIColor.whiteColor()
init(modal:Event){
self.dateLabelData = modal.date
self.mixLabelData = modal.eventTitle
self.color = UIColor.redColor()
}
init(modal:Festival){
self.dateLabelData = modal.date
self.mixLabelData = modal.festivalName
self.color = UIColor.whiteColor()
}
}


最后我们终于可以去修改我们的控制器了,控制器中需要更改的是与cell有关的datasource方法:

override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier(cellReusedID, forIndexPath: indexPath) as! ShowedTableViewCell
if let event = dataList[indexPath.row] as? Event{
let viewModel = ViewModel(modal: event)
cell.updateWithPresenter(viewModel)
return cell
} else if let festival = dataList[indexPath.row] as? Festival{
let viewModel = ViewModel(modal: festival)
cell.updateWithPresenter(viewModel)
return cell
} else {
return cell

}
}
}


这段代码写的我满头大汗,编译运行,幸运的是运行的结果是正确的:



我在想MVVM模式的意义是什么?我在使用MVVM之前甚至需要考虑一下值不值得花时间去写成MVVM的模样,因为MVVM需要给所有的view提供协议,并且将所有的数据模型的绑定过程写进一个新的数据结构ViewModal中,但其实这个ViewModel的价值非常之小,除了数据绑定,没有其他作用了,里面甚至只有空洞的init构造器,我想我已经决定放弃这个思路了。

MV的萌芽阶段

我继续着自己的思考,大会上傅若愚前辈分享的示例给了我很大的启发,因为他提供了一个没有中间层的模型!我一直在思考这个模型,并且在入职的第一个项目中一直在按照他的模型来组织自己的代码,直到我顿悟了自己的MV模型。下面简单介绍一下傅若愚前辈的思路,这个思路的优势在于所有的数据和模型绑定都只需要两个通用的协议:

//视图使用的协议
protocol RenderContext{
func renderText(texts:String...)
func renderImage(images:UIImage...)
}
//数据使用的协议
protocol ViewModelType{
func renderInContext(context:RenderContext)
}


上面是大会上傅若愚前辈的原版,在介绍这个协议的用法之前,我觉得应该先做一点点改进,ViewModalType应该改成:

protocol ViewModelType{
func renderInContext<R:RenderContext>(context:R)
}


这两个版本都可以通过编译,差别在运行的效率上,下面我在playground中展示一个示例,这个示例来源于《Advanced Swift》这本书,其实苹果的WWDC2015 408号视频中也明确表述了不要把协议当做参数类型,而写成泛型的约束,但是没有详细讲解为什么,下面是示例:

func takesProtocol(x: CustomStringConvertible) { //
print ( sizeofValue(x))
}
func takesPlaceholder<T: CustomStringConvertible>(x: T) {
print ( sizeofValue(x))
}


两个方法,前者使用协议作为参数的类型,后者使用协议作为泛型的约束条件,两个方法都会打印参数的长度,调用一下试试:

takesProtocol(1 as Int16)
takesPlaceholder(1 as Int16)


打印结果:



换成类再打印一次:



没错,由于协议本身既可以被类遵守、也可以被结构体、枚举遵守,也就是说既可以被引用类型遵守也可以被值类型遵守,把协议当做参数类型,实际上会创造一个Box类型,里面会为引用类型遵守者预留地址也会为值类型遵守者预留地址,甚至需要存储一个指针长度找到协议的真正继承类型。而Swift2.0之后编译器得到了加强,具有了泛型特化的功能,对代码中的泛型在编译时就会确定其真正的类型,不耗费任何性能。

下面我们用改造后的傅若愚前辈的协议来改造Demo,你需要让你的数据模型去遵守RenderContext,然后根据模型的参数类型将每一个参数存入对应类型方法的参数列表中,这些方法都是可变参数,不限制数量,但是参数的类型是确定的。这种使用参数类型做通用类型的写法消灭了中间的ViewModel层,把Model和View直接对接了。由于Swift要求每一个协议的遵守者都必须实现协议的全部方法,而有些方法的数据模型并没有,所以你在使用之前需要使用协议扩展为这些方法实现一个空的实现:

protocol RenderContext{
func renderText(texts:String...)
func renderImage(images:UIImage...)
}

extension RenderContext{
func renderText(texts:String...){

}
func renderImage(images:UIImage...){

}
}


现在你的模型应该是下面这样:

struct Event:HasDate,ViewModelType{
var date = ""
var eventTitle = ""
func renderInContext<R : RenderContext>(context: R) {
context.renderText(date,eventTitle)
}
}

struct Festival:HasDate,ViewModelType{
var date = ""
var festivalName = ""
func renderInContext<R : RenderContext>(context: R) {
context.renderText(date,festivalName)
}
}


视图的代码应该是这样的:

class ShowedTableViewCell: UITableViewCell,RenderContext {
//用来展示事件主题或节日名称的Label
@IBOutlet weak var MixLabel: UILabel!
//用来展示日期的Label
@IBOutlet weak var dateLabel: UILabel!

func renderText(texts: String...) {
dateLabel.text = texts[0]
MixLabel.text = texts[1]
}
}


由于遵守了多个协议,所以控制器中原本的异构类型不合适了,此时可以给多个协议类型写一个别名方便使用,记得顺便更新一下你的Model,提高可读性:

typealias DateViewModel = protocol<HasDate,ViewModelType>


现在控制器中的数据源可以使用新的异构类型了:

var dataList = [DateViewModel]()
var loadeddataList:[DateViewModel] = [Event(date: "2月14", eventTitle: "送礼物"),Festival(date: "1月1日", festivalName: "元旦"),Festival(date: "2月14", festivalName: "情人节")]


然后更新cell的代理方法:

override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier(cellReusedID, forIndexPath: indexPath) as! ShowedTableViewCell
dataList[indexPath.row].renderInContext(cell)
return cell
}


不错,代码简洁了很多,运行一下:



等等,我们似乎遗漏了一些东西,cell的背景颜色呢?好吧让我们加上,可是我该去哪里加呢?去控制器中吗?不不坚决不能碰控制器,那么只能去cell中了,现在问题出现了,当两个模型共享一个视图的时候,我该如何判断数据源从哪里来?renderText(texts: String…)这样的写法已经完全失去了异构的特性,那么试着这样写,在数据传递参数的时候多传一个String好了,反正参数是我们的自由:

struct Event:DateViewModel{
var date = ""
var eventTitle = ""
func renderInContext<R : RenderContext>(context: R) {
context.renderText(date,eventTitle,"red")
}
}


这样在检验的时候就看最后一个参数就好了:

class ShowedTableViewCell: UITableViewCell,RenderContext {
//用来展示事件主题或节日名称的Label
@IBOutlet weak var MixLabel: UILabel!
//用来展示日期的Label
@IBOutlet weak var dateLabel: UILabel!

func renderText(texts: String...) {
dateLabel.text = texts[0]
MixLabel.text = texts[1]
if texts[2] == "red"{
backgroundColor = UIColor.redColor()
}
}
}


这里有个语法糖,可变参数的方法,在取参时不会发生越界,因为Festival的renderText方法只传了两个值,运行结果又正常了。那么如果我粗心把参数写错顺序了呢?结果成了这样:



如果我的Festival中多了一个Int类型,而Event中恰巧没有呢?按照值去区分参数不是一个好主意,因为你用下标从一个数组中取值的时候除了它的类型不能得到任何信息,甚至都不知道这个值存不存在!我再次陷入了思考,既然View需要的是Model中的属性,这不就等于需要Model自己么,那么为什么我们不能直接传递Modal自己呢?

MV!

所以我再次改造了傅若愚前辈的协议,顺便把名字改的好辨认一点,原来的名字太容易出错了- -现在它是这样子的:

//视图使用的协议
protocol ViewType{
func getData<M:ModelType>(model:M)
}
//数据使用的协议
protocol ModelType{
func giveData<V:ViewType>(view:V)
}


不需要在扩展中写默认实现,因为传值是相互且确定的,所以方法一定会被实现。

模型是这样子的:

typealias DateViewModel = protocol<HasDate,ModelType>
struct Festival:DateViewModel{
var date = ""
var festivalName = ""
func giveData<V : ViewType>(view: V) {
view.getData(self)
}
}

struct Event:DateViewModel{
var date = ""
var eventTitle = ""
func giveData<V : ViewType>(view: V) {
view.getData(self)
}
}


视图:

class ShowedTableViewCell: UITableViewCell,ViewType {
//用来展示事件主题或节日名称的Label
@IBOutlet weak var MixLabel: UILabel!
//用来展示日期的Label
@IBOutlet weak var dateLabel: UILabel!

func getData<M : ModelType>(model: M) {
//这里不能写成guard let dateModel = model as? DateViewModel else{}令我有些意外
guard let dateModel = model as? HasDate else{
//不满足Cell基本需求的Model直接淘汰掉
return
}
//处理相同属性
dateLabel.text = dateModel.date
//处理数据源异构
if let event = dateModel as? Event{
MixLabel.text = event.eventTitle
backgroundColor = UIColor.redColor()
} else if let festival = dateModel as? Festival{
MixLabel.text = festival.festivalName
backgroundColor = UIColor.whiteColor()
}
}
}


再次用苹果官方给出的异构判断方法解决异构,协议不同于类,没有那么多继承上的检查,所以使用as?是很高效的,最后只要给控制器中的代码换个名字就够了:

override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier(cellReusedID, forIndexPath: indexPath) as! ShowedTableViewCell
dataList[indexPath.row].giveData(cell)
return cell
}


完成,运行效果:



你会发现即便你删掉你的控制器代码,View和Model中的逻辑也不会发生改变,而在控制器中进行数据绑定的时候,因为使用了协议来实现数据源的异构,你甚至不需要对数据源做筛选,只要它们是使用相同的cell来做展示。

等等,还没完!即便这个架构已经非常精简了,但是你也许发现了在Model中我们写了很多重复的代码,也就是giveData的实现部分,这个实现是完全相同的,而且我们不希望这个方法被复习,所以,最后的优化,把giveData的定义和实现放在ModelType的协议扩展中:

//视图使用的协议
protocol ViewType{
func getData<M:ModelType>(model:M)
}
//数据使用的协议
protocol ModelType{
}
//ModelType的默认实现
extension ModelType{
func giveData<V:ViewType>(view:V){
view.getData(self)
}
}


现在你可以删掉Event和Festival中的giveData的实现了,Model、View、Controller和Protocol的代码都变得极其简单,依靠Swift强大的语言特性,让数据的传递与异构的处理似乎看不见摸不着,却又真实地发生了,幽灵般鬼魅。寥寥数行代码解决了MVC和MVVM争论多年的问题,运行一下,享受幽灵架构MV吧!

在项目的最初阶段,开发人员拿到的是原型和设计图,即便我们不清楚该如何开始编写复杂的处理逻辑,但是数据的状态与视图的样式的对应关系是大致确定的,因此可以直接使用MV架构绑定数据源和视图的逻辑,即便后台开发者提供的最终接口里有数据的改变,那么在我们修改对应的Model的时候,View中的代码也会以报错的形式提示你修改,开发的效率会得到一个显著的提升!

代码打包了一份,放这里了:http://pan.baidu.com/s/1qYTAs3M

有需要的自取

写在后面:

博主欠了欠身子,从吃完晚饭写到了半夜,一口气完成了本文,如果你喜欢我的文章并且得到了启发,欢迎转载传阅,注明出处即可。在Swift1.X时代我觉得Swift脆弱的像只小猫,Swift2.0之后我才突然发现苹果缔造的是一只野兽。苹果很聪明,在推进Swift替代OC的道路上采取了温柔的手段:仅仅是让Swift变得更优秀。通过不断锻炼自己面向协议编程的能力,我有了很多新的体会,想起了迪杰斯特拉老爷子著名的goto有害论,请准许我大胆预言一下,在面向协议的世界中AnyObject也是有害的,点到为止。

关于博主本人:

《Swift开发手册:技巧与实战》作者。国内计算机领域的某名校毕业,学习不差,初入社会,曾只身离校北漂妄图以退学抗议畸形的研究生教育,后心疼父母返校完成学业。从2014年底开始接触Swift后一发不可收拾,至今保持狂热,小人物大梦想,孜孜不倦致力于改善iOS编程体验。欢迎大家留言交流,力所能及之处,必倾囊相授。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: