资讯

精准传达 • 有效沟通

从品牌网站建设到网络营销策划,从策略到执行的一站式服务

怎么写好一个UITableView

这篇文章主要介绍“怎么写好一个UITableView”,在日常操作中,相信很多人在怎么写好一个UITableView问题上存在疑惑,小编查阅了各式资料,整理出简单好用的操作方法,希望对大家解答”怎么写好一个UITableView”的疑惑有所帮助!接下来,请跟着小编一起来学习吧!

成都创新互联公司咨询电话:18980820575,为您提供成都网站建设网页设计及定制高端网站建设服务,成都创新互联公司网页制作领域十载,包括办公窗帘等多个行业拥有多年的网站设计经验,选择成都创新互联公司,为企业保驾护航。

如果你觉得 `UITableViewDelegate` 和 `UITableViewDataSource` 这两个协议中有大量方法每次都是复制粘贴,实现起来大同小异;如果你觉得发起网络请求并解析数据需要一大段代码,加上刷新和加载后简直复杂度爆表,如果你想知道为什么下面的代码可以满足上述所有要求:

怎么写好一个UITableView

MVC

在讨论解耦之前,我们要弄明白 MVC 的核心:控制器(以下简称 C)负责模型(以下简称 M)和视图(以下简称 V)的交互。

这里所说的 M,通常不是一个单独的类,很多情况下它是由多个类构成的一个层。最上层的通常是以 `Model` 结尾的类,它直接被 C 持有。`Model` 类还可以持有两个对象:

1.  Item:它是实际存储数据的对象。它可以理解为一个字典,和 V 中的属性一一对应

2.  Cache:它可以缓存自己的 Item(如果有很多)

常见的误区:

1.  一般情况下数据的处理会放在 M 而不是 C(C 只做不能复用的事)

2.  解耦不只是把一段代码拿到外面去。而是关注是否能合并重复代码, 并且有良好的拖展性。

原始版

在 C 中,我们创建 `UITableView` 对象,然后将它的数据源和代理设置为自己。也就是自己管理着 UI 逻辑和数据存取的逻辑。在这种架构下,主要存在这些问题:

1.  违背 MVC 模式,现在是 V 持有 C 和 M。

2.  C 管理了全部逻辑,耦合太严重。

3.  其实绝大多数 UI 相关都是由 Cell 而不是 `UITableView` 自身完成的。

为了解决这些问题,我们首先弄明白,数据源和代理分别做了那些事。

数据源

它有两个必须实现的代理方法:

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section;
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath;

简单来说,只要实现了这个两个方法,一个简单的 `UITableView` 对象就算是完成了。

除此以外,它还负责管理 `section` 的数量,标题,某一个 `cell` 的编辑和移动等。

代理

代理主要涉及以下几个方面的内容:

1.  cell、headerView 等展示前、后的回调。

2.  cell、headerView 等的高度,点击事件。

最常用的也是两个方法:

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath;
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath;

提醒:绝大多数代理方法都有一个 `indexPath` 参数

优化数据源

最简单的思路是单独把数据源拿出来作为一个对象。

这种写法有一定的解耦作用,同时可以有效减少 C 中的代码量。然而总代码量会上升。我们的目标是减少不必要的代码。

比如获取每一个 `section` 的行数,它的实现逻辑总是高度类似。然而由于数据源的具体实现方式不统一,所以每个数据源都要重新实现一遍。

SectionObject

首先我们来思考一个问题,数据源作为 M,它持有的 Item 长什么样?答案是一个二维数组,每个元素保存了一个 `section` 所需要的全部信息。因此除了有自己的数组(给cell用)外,还有 section 的标题等,我们把这样的元素命名为 `SectionObject`:

@interface KtTableViewSectionObject : NSObject
@property (nonatomic, copy) NSString *headerTitle; // UITableDataSource 协议中的 titleForHeaderInSection 方法可能会用到
@property (nonatomic, copy) NSString *footerTitle; // UITableDataSource 协议中的 titleForFooterInSection 方法可能会用到
@property (nonatomic, retain) NSMutableArray *items;
- (instancetype)initWithItemArray:(NSMutableArray *)items;
@end

Item

其中的 `items` 数组,应该存储了每个 cell 所需要的 `Item`,考虑到 `Cell` 的特点,基类的 `BaseItem` 可以设计成这样:

@interface KtTableViewBaseItem : NSObject
@property (nonatomic, retain) NSString *itemIdentifier;
@property (nonatomic, retain) UIImage *itemImage;
@property (nonatomic, retain) NSString *itemTitle;
@property (nonatomic, retain) NSString *itemSubtitle;
@property (nonatomic, retain) UIImage *itemAccessoryImage;
- (instancetype)initWithImage:(UIImage *)image Title:(NSString *)title SubTitle:(NSString *)subTitle AccessoryImage:(UIImage *)accessoryImage;
@end

父类实现代码

规定好了统一的数据存储格式以后,我们就可以考虑在基类中完成某些方法了。以 `- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section` 方法为例,它可以这样实现:

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    if (self.sections.count > section) {
        KtTableViewSectionObject *sectionObject = [self.sections objectAtIndex:section];
        return sectionObject.items.count;
    }
    return 0;
}

比较困难的是创建 `cell`,因为我们不知道 `cell` 的类型,自然也就无法调用 `alloc` 方法。除此以外,`cell` 除了创建,还需要设置 UI,这些都是数据源不应该做的事。

这两个问题的解决方案如下:

1.  定义一个协议,父类返回基类 `Cell`,子类视情况返回合适的类型。

2.  为 `Cell` 添加一个 `setObject` 方法,用于解析 Item 并更新 UI。

优势

经过这一番折腾,好处是相当明显的:

1.  子类的数据源只需要实现 `cellClassForObject` 方法即可。原来的数据源方法已经在父类中被统一实现了。

2.  每一个 Cell 只要写好自己的 `setObject` 方法,然后坐等自己被创建,被调用这个方法即可。

3.  子类通过 `objectForRowAtIndexPath` 方法可以快速获取 item,不用重写。

对照 demo(SHA-1:6475496),感受一下效果。

优化代理

我们以之前所说的,代理协议中常用的两个方法为例,看看怎么进行优化与解耦。

首先是计算高度,这个逻辑并不一定在 C 完成,由于涉及到 UI,所以由 Cell 负责实现即可。而计算高度的依据就是 Object,所以我们给基类的 Cell 加上一个类方法:

+ (CGFloat)tableView:(UITableView*)tableView rowHeightForObject:(KtTableViewBaseItem *)object;

另外一类问题是以处理点击事件为代表的代理方法, 它们的主要特点是都有 `indexPath` 参数用来表示位置。然而实际在处理过程中,我们并不关系位置,关心的是这个位置上的数据。

因此,我们对代理方法做一层封装,使得 C 调用的方法中都是带有数据参数的。因为这个数据对象可以从数据源拿到,所以我们需要能够在代理方法中获取到数据源对象。

为了实现这一点, 最好的办法就是继承 `UITableView`:

@protocol KtTableViewDelegate
@optional
- (void)didSelectObject:(id)object atIndexPath:(NSIndexPath*)indexPath;
- (UIView *)headerViewForSectionObject:(KtTableViewSectionObject *)sectionObject atSection:(NSInteger)section;
// 将来可以有 cell 的编辑,交换,左滑等回调
// 这个协议继承了UITableViewDelegate ,所以自己做一层中转,VC 依然需要实现某
@end
@interface KtBaseTableView : UITableView
@property (nonatomic, assign) id ktDataSource;
@property (nonatomic, assign) id ktDelegate;
@end

cell 高度的实现如下,调用数据源的方法获取到数据:

- (CGFloat)tableView:(UITableView*)tableView heightForRowAtIndexPath:(NSIndexPath*)indexPath {
    id dataSource = (id)tableView.dataSource;
    KtTableViewBaseItem *object = [dataSource tableView:tableView objectForRowAtIndexPath:indexPath];
    Class cls = [dataSource tableView:tableView cellClassForObject:object];
    return [cls tableView:tableView rowHeightForObject:object];
}

优势

通过对 `UITableViewDelegate` 的封装(其实主要是通过 `UITableView` 完成),我们获得了以下特性:

1.  C 不用关心 Cell 高度了,这个由每个 Cell 类自己负责

2.  如果数据本身存在数据源中,那么在代理协议中它可以被传给 C,免去了 C 重新访问数据源的操作。

3.  如果数据不存在于数据源,那么代理协议的方法会被正常转发(因为自定义的代理协议继承自 `UITableViewDelegate`)

对照 demo(SHA-1:ca9b261),感受一下效果。

 更加 MVC,更加简洁

在上面的两次封装中,其实我们是把 `UITableView` 持有原生的代理和数据源,改成了 `KtTableView` 持有自定义的代理和数据源。并且默认实现了很多系统的方法。

到目前为止,看上去一切都已经完成了,然而实际上还是存在一些可以改进的地方:

1.  目前仍然不是 MVC 模式!

2.  C 的逻辑和实现依然可以进一步简化

基于以上考虑, 我们实现一个 `UIViewController` 的子类,并且把数据源和代理封装到 C 中。

@interface KtTableViewController : UIViewController
@property (nonatomic, strong) KtBaseTableView *tableView;
@property (nonatomic, strong) KtTableViewDataSource *dataSource;
@property (nonatomic, assign) UITableViewStyle tableViewStyle; // 用来创建 tableView
- (instancetype)initWithStyle:(UITableViewStyle)style;
@end

为了确保子类创建了数据源,我们把这个方法定义到协议里,并且定义为 `required`。

成果与目标

现在我们梳理一下经过改造的 `TableView` 该怎么用:

1.  首先你需要创建一个继承自 `KtTableViewController` 的视图控制器,并且调用它的 `initWithStyle` 方法。

    `objc KTMainViewController *mainVC = [[KTMainViewController alloc] initWithStyle:UITableViewStylePlain];`

2.  在子类 VC 中实现 `createDataSource` 方法,实现数据源的绑定。

    ```objc

    *   (void)createDataSource { self.dataSource = [[KtMainTableViewDataSource alloc] init]; // 这 一步创建了数据源 } ```

3.  在数据源中,需要指定 cell 的类型。

    ```objc

    *   (Class)tableView:(UITableView *)tableView cellClassForObject:(KtTableViewBaseItem *)object { return [KtMainTableViewCell class]; } ```

4.  在 Cell 中,需要通过解析数据,来更新 UI 并返回自己的高度。

    objc

    *   (CGFloat)tableView:(UITableView *)tableView rowHeightForObject:(KtTableViewBaseItem *)object { return 60; } // Demo 中沿用了父类的 setObject 方法。 ```

还有什么要优化的

到目前为止,我们实现了对 `UITableView` 以及相关协议、方法的封装,使它更容易使用,避免了很多重复、无意义的代码。

在使用时,我们需要创建一个控制器,一个数据源,一个自定义 Cell,它们正好是基于 MVC 模式的。因此,可以说在封装与解耦方面,我们已经做的相当好了,即使再花大力气,也很难有明显的提高。

但关于 `UITableView` 的讨论远远没有结束,我列出了以下需要解决的问题

1.  在这种设计下,数据的回传不够方便,比如 cell 的给 C 发消息。

2.  下拉刷新与上拉加载如何集成

3.  网络请求的发起,与解析数据如何集成

关于第一个问题,其实是普通的 MVC 模式中 V 和 C 的交互问题,可以在 Cell(或者其他类) 中添加 weak 属性达到直接持有的目的,也可以定义协议。

问题二和三是另一大块话题,网络请求大家都会实现,但如何优雅的集成进框架,保证代码的简单和可拓展,就是一个值得深入思考,研究的问题了。接下来我们就重点讨论网络请求。

为何创建网络层

一个 iOS 的网络层框架该如何设计?这是一个非常宽泛,也超出我能力范围之外的问题。业内已有一些优秀的,成熟的思路和解决方案,由于能力,角色所限,我决定从一个普通开发者而不是架构师的角度来说说,一个普通的、简单的网络层该如何设计。我相信再复杂的架构,也是由简单的设计演化而来的。

对于绝大多数小型应用来说,集成 `AFNetworking` 这样的网络请求框架就足以应付 99% 以上的需求了。但是随着项目的扩大,或者用长远的眼光来考虑,直接在 VC 中调用具体的网络框架(下面以 `AFNetworking` 为例),至少存在以下问题:

1.  一旦日后 `AFNetworking` 停止维护,而且我们需要更换网络框架,这个成本将无法想象。所有的 VC 都要改动代码,而且绝大多数改动都是雷同的。

    这样的例子真实存在,比如我们的项目中就依然使用早已停止维护的 `ASIHTTPRequest`,可以预见,这个框架迟早要被替换。

2.  现有的框架可能无法实现我们的需求。以 `ASIHTTPRequest` 为例,它的底层用 `NSOperation` 来表示每一个网络请求。众所周知,一个 `NSOperation` 的取消,并不是简单调用 `cancel` 方法就可以的。在不修改源码的前提下,一旦它被放入队列,其实是无法取消的。

3.  有时候我们的需求仅仅是进行网络请求,还会对这个请求进行各种自定义的拓展。比如我们可能要统计请求的发起和结束时间,从而计算网络请求,数据解析的步骤的耗时。有时候,我们希望设计一个通用组件,并且支持由各个业务部门去自定义具体的规则。比如可能不同的部门,会为 HTTP 请求添加不同的头部。

4.  网络请求还有可能有其他广泛需要添加的需求,比如请求失败时的弹窗,请求时的日志记录等等。

参考当前代码(SHA-1:a55ef42)感受一下没有任何网络层时的设计。

如何设计网络层

其实解决方案非常简单:

所有的计算机问题,都可以通过添加中间层来解决

读者可以自行思考,为什么添加中间层可以解决上述三个问题。

三大模块

对于一个网络框架来说,我认为主要有三个方面值得去设计:

1.  如何请求

2.  如何回调

3.  数据解析

一个完整的网络请求一般由以上三个模块组成,我们逐一分析每个模块实现时的注意事项:

### 发起请求

发起请求时,一般有两种思路,第一种是把所有要配置的参数写到同一个方法中,借用 [与时俱进,HTTP/2下的iOS网络层架构设计](http://www.jianshu.com/p/a9bca62d8dab) 一文中的代码表示:

+ (void)networkTransferWithURLString:(NSString *)urlString
                       andParameters:(NSDictionary *)parameters
                              isPOST:(BOOL)isPost
                        transferType:(NETWORK_TRANSFER_TYPE)transferType
                   andSuccessHandler:(void (^)(id responseObject))successHandler
                   andFailureHandler:(void (^)(NSError *error))failureHandler {
                           // 封装AFN
                   }

这种写法的好处在于所有参数一目了然,而且简单易用,每次都调用这个方法即可。但是缺点也很明显,随着参数和调用次数的增多,网络请求的代码很快多到爆炸。

另一组方法则是将 API 设置成一个对象,把要传入的参数作为这个对象的属性。在发起请求时,只要设置好对象的相关属性,然后调用一个简单的方法即可。

@interface DRDBaseAPI : NSObject
@property (nonatomic, copy, nullable) NSString *baseUrl;
@property (nonatomic, copy, nullable) void (^apiCompletionHandler)(_Nonnull id responseObject,  NSError * _Nullable error);
- (void)start;
- (void)cancel;
...
@end

根据前文提到的 Model 和 Item 的概念,那么应该可以想到:**这个用于访问网络的 API 对象,其实是作为 Model 的一个属性**。

Model 负责对外暴露必要的属性和方法,而具体的网络请求则由 API 对象完成,同时 Model 也应该持有真正用来存储数据的 Item。

如何回调

一次网络请求的返回结果应该是一个 JSON 格式的字符串,通过系统的或者一些开源框架可以将它转换成字典。

接下来我们需要使用 runtime 相关的方法,将字典转换成 Item 对象。

最后,Model 需要将这个 Item 赋值给自己的属性,从而完成整个网络请求。

如果从全局角度来说,我们还需要一个 Model 请求完成的回调,这样 VC 才能有机会做相应的处理。

考虑到 Block 和 Delegate 的优缺点,我们选择用 Block 来完成回调。

[数据解析](https://bestswifter.com/how-to-create-an-uitableview/#)

这一部分主要是利用 runtime 将字典转换成 Item,它的实现并不算难,但是如何隐藏好实现细节,使上层业务不用过多关心,则是我们需要考虑的问题。

我们可以定义一个基类的 Item,并且为它定义一个 `parseData` 函数:

// KtBaseItem.m
- (void)parseData:(NSDictionary *)data {
    // 解析 data 这个字典,为自己的属性赋值
    // 具体的实现请见后面的文章
}

封装 API 对象

首先,我们封装一个 `KtBaseServerAPI` 对象,这个对象的主要目的有三个:

1.  隔离具体的网络库的实现细节,为上层提供一个稳定的的接口

2.  可以自定义一些属性,比如网络请求的状态,返回的数据等,方便的调用

3.  处理一些公用的逻辑,比如网络耗时统计

具体的实现请参考 Git 提交历史:SHA-1:76487f7

Model 与 Item

BaseModel

Model 主要需要负责发起网络请求,并且处理回调,来看一下基类的 Model 如何定义:

@interface KtBaseModel
// 请求回调
@property (nonatomic, copy) KtModelBlock completionBlock;
//网络请求
@property (nonatomic,retain) KtBaseServerAPI *serverApi;
//网络请求参数
@property (nonatomic,retain) NSDictionary *params;
//请求地址 需要在子类init中初始化
@property (nonatomic,copy)   NSString *address;
//model缓存
@property (retain,nonatomic) KtCache *ktCache;

它通过持有 API 对象完成网络请求,可以定制自己的存储逻辑,控制请求方式的选择(长、短链接,JSON或protobuf)。

Model 应该对上层暴露一个非常简单的调用接口,因为假设一个 Model 对应一个 URL,其实每次请求只需要设置好参数,就可以调用合适的方法发起请求了。

由于我们不能预知请求何时结束,所以需要设置请求完成时的回调,这也需要作为 Model 的一个属性。

BaseItem

基类的 Item 主要是负责 property name 到 json path 的映设,以及 json 数据的解析。最核心的字典转模型实现如下:

- (void)parseData:(NSDictionary *)data {
    Class cls = [self class];
    while (cls != [KtBaseItem class]) {
        NSDictionary *propertyList = [[KtClassHelper sharedInstance] propertyList:cls];
        for (NSString *key in [propertyList allKeys]) {
            NSString *typeString = [propertyList objectForKey:key];
            NSString* path = [self.jsonDataMap objectForKey:key];
            id value = [data objectAtPath:path];
            [self setfieldName:key fieldClassName:typeString value:value];
        }
        cls = class_getSuperclass(cls);
    }
}

完整代码参考 Git 提交历史:SHA-1:77c6392

如何使用

在实际使用时,首先要创建子类的 Modle 和 Item。子类的 Model 应该持有 Item 对象,并且在网络请求回调时,将 API 中携带的 JSON 数据赋值给 Item 对象。

这个 JSON 转对象的过程在基类的 Item 中实现,子类的 Item 在创建时,需要指定属性名和 JSON 路径之间的对应关系。

对于上层来说,它需要生成一个 Model 对象,设置好它的路径以及回调,这个回调一般是网络请求返回时 VC 的操作,比如调用 `reloadData` 方法。这时候的 VC 可以确定,网络请求的数据就存在 Model 持有的 Item 对象中。

具体代码参考 Git 提交历史:SHA-1:8981e28

下拉刷新

很多应用的 `UITableview` 都具有下拉刷新和上拉加载的功能,在实现这个功能时,我们主要考虑两点:

1.  隐藏底层的实现细节,对外暴露稳定易用的接口

2.  Model 和 Item 如何实现

第一点已经是老生常谈,参考 SHA-1 61ba974 就可以看到如何实现一个简单的封装。

重点在于对于 Model 和 Item 的改造。

ListItem

这个 Item 没有什么别的作用,就是定义了一个属性 `pageNumber`,这是需要与服务端协商的。Model 将会根据这个属性这个属性判断有没有全部加载完。

// In .h
@interface KtBaseListItem : KtBaseItem
@property (nonatomic, assign) int pageNumber;
@end
// In .m
- (id)initWithData:(NSDictionary *)data {
    if (self = [super initWithData:data]) {
        self.pageNumber = [[NSString stringWithFormat:@"%@", [data objectForKey:@"page_number"]] intValue];
    }
    return self;
}

对于 Server 来说,如果每次都返回 `page_number` 无疑是非常低效的,因为每次参数都可能不同,计算总数据量是一项非常耗时的工作。因此在实际使用中,客户端可以和 Server 约定,返回的结果中带有 `isHasNext` 字段。通过这个字段,我们一样可以判断是否加载到最后一页。

ListModel

它持有一个 `ListItem` 对象, 对外暴露一组加载方法,并且定义了一个协议 `KtBaseListModelProtocol`,这个协议中的方法是请求结束后将要执行的方法。

@protocol KtBaseListModelProtocol 
@required
- (void)refreshRequestDidSuccess;
- (void)loadRequestDidSuccess;
- (void)didLoadLastPage;
- (void)handleAfterRequestFinish; // 请求结束后的操作,刷新tableview或关闭动画等。
@optional
- (void)didLoadFirstPage;
@end
@interface KtBaseListModel : KtBaseModel
@property (nonatomic, strong) KtBaseListItem *listItem;
@property (nonatomic, weak) id delegate;
@property (nonatomic, assign) BOOL isRefresh; // 如果为是,表示刷新,否则为加载。
- (void)loadPage:(int)pageNumber;
- (void)loadNextPage;
- (void)loadPreviousPage;
@end

实际上,当 Server 端发生数据的增删时,只传 `nextPage` 这个参数是不能满足要求的。两次获取的页面并非完全没有交集,很有可能他们具有重复元素,所以 Model 还应该肩负起去重的任务。为了简化问题,这里就不完整实现了。

RefreshTableViewController

它实现了 `ListMode` 中定义的协议,提供了一些通用的方法,而具体的业务逻辑则由子类实现。

#pragma -mark KtBaseListModelProtocol
- (void)loadRequestDidSuccess {
    [self requestDidSuccess];
}
- (void)refreshRequestDidSuccess {
    [self.dataSource clearAllItems];
    [self requestDidSuccess];
}
- (void)handleAfterRequestFinish {
    [self.tableView stopRefreshingAnimation];
    [self.tableView reloadData];
}
- (void)didLoadLastPage {
    [self.tableView.mj_footer endRefreshingWithNoMoreData];
}
#pragma -mark KtTableViewDelegate
- (void)pullUpToRefreshAction {
    [self.listModel loadNextPage];
}
- (void)pullDownToRefreshAction {
    [self.listModel refresh];
}

实际使用

在一个 VC 中,它只需要继承 `RefreshTableViewController`,然后实现 `requestDidSuccess` 方法即可。下面展示一下 VC 的完整代码,它超乎寻常的简单:

- (void)viewDidLoad {
    [super viewDidLoad];
    [self createModel];
    // Do any additional setup after loading the view, typically from a nib.
}
- (void)createModel {
    self.listModel = [[KtMainTableModel alloc] initWithAddress:@"/mooclist.php"];
    self.listModel.delegate = self;
}
- (void)createDataSource {
    self.dataSource = [[KtMainTableViewDataSource alloc] init]; // 这一步创建了数据源
}
- (void)didReceiveMemoryWarning {
    [super didReceiveMemoryWarning];
    // Dispose of any resources that can be recreated.
}
- (void)requestDidSuccess {
    for (KtMainTableBookItem *book in ((KtMainTableModel *)self.listModel).tableViewItem.books) {
        KtTableViewBaseItem *item = [[KtTableViewBaseItem alloc] init];
        item.itemTitle = book.bookTitle;
        [self.dataSource appendItem:item];
    }
}

其他的判断,比如请求结束时关闭动画,最后一页提示没有更多数据,下拉刷新和上拉加载触发的方法等公共逻辑已经被父类实现了。

到此,关于“怎么写好一个UITableView”的学习就结束了,希望能够解决大家的疑惑。理论与实践的搭配能更好的帮助大家学习,快去试试吧!若想继续学习更多相关知识,请继续关注创新互联网站,小编会继续努力为大家带来更多实用的文章!


文章题目:怎么写好一个UITableView
标题路径:http://www.cdkjz.cn/article/gcgejp.html
多年建站经验

多一份参考,总有益处

联系快上网,免费获得专属《策划方案》及报价

咨询相关问题或预约面谈,可以通过以下方式与我们联系

大客户专线   成都:13518219792   座机:028-86922220