文章目录
  1. 1. 文档更新说明
  2. 2. 前言
  3. 3. 做什么?
  4. 4. 思考分工
  5. 5. 具体设计
    1. 5.1. 主界面
      1. 5.1.1. Tab 控制器
      2. 5.1.2. 列表控制器
    2. 5.2. 数据库设计
      1. 5.2.1. 接收表, 下载表
      2. 5.2.2. 发送订单表, 文件发送表, 用户信息表
      3. 5.2.3. 数据库管理模块
    3. 5.3. 文件传输
      1. 5.3.1. 控制并发数
      2. 5.3.2. 启动传输
      3. 5.3.3. 传输进度获取
      4. 5.3.4. 断点续传
    4. 5.4. 传输状态通知
  6. 6. 总结

文档更新说明

  • 最后更新 2020年10月24日
  • 首次更新 2020年10月24日

前言

  经过一个月的高强度开发, 终于在本周五提测了Mac端的文件传输管理模块的新功能, 现在可以抽空写一写总结了.

本文主要是讲述我是如何与团队成员分工合作开发一个完整功能, 包括如何高效率分工, 如何拆分模块, 如何使用相关的开发工具有效率地完成任务等, 代码方面就不会有太多了, 毕竟这篇文章主要是讲述思考和设计嘛.

做什么?

  先来一张设计图, 看看这个月做了什么功能.

文件传输和管理, 我们称他为文件共享, 说白了, 就是让一个企业的内部员工之前可以方便传输文件. 因为我们做的是安全管控软件, 所以文件之间的传输是被严格管控的, 甚至还需要人工审核. 这些都是和具体产品相关的功能, 就不多说了.

抽像出来, 这个模块的作用, 就是能让用户选择客户端上的文件, 再指定一个企业内部用户, 将文件发送给他. 或者接收其他同事发送过来的文件.

思考分工

  这个功能由我和另一个同事两人负责, 时间是一个月. 因为我对整个项目的各个大模块都有涉及开发了解, 比较清晰理解项目情况, 所以拿到需求的第一时间, 我就大概想出分工结果了. 分工虽然是有方法论, 比如考虑模块化, 解耦, 封装和扩展等, 但是肯定要符合项目的实际情况.

该模块的核心功能点就是文件的上传下载, 断点续传, 所以从设计的角度上说, 肯定需要把文件传输单独拎出来实现.

从解藕的角度考虑, 观察者模式再适合不过了, 文件传输模块需要对外提供一些可供观察的入口, 比如文件传输的进度, 文件传输的成功或者失败等. 之后UI层面订阅好事件, 按需更新自己的UI即可.

而数据持久化, 这个还得配合实际后台接口来考虑. 我们的后台接口, 只负责提供最新的消息, 比如别人共享一个或多个文件过来, 那么接收方会收到最新消息, 再调用一系列接口得知文件的信息(细节就不讨论了), 但是后台并没有提供历史记录接口, 所以我们数据持久化就需要持久化全部历史数据, 下载记录, 上传记录等. 考虑到易用性问题, 我选择苹果推荐的CoreData, 配合SQLite. CoreData这个入门有难度, 但是真的非常方便.
  
下面是考虑到项目实际情况后初步设计出的:   

  1. 从UI上考虑功能的完整性, 文件共享的申请界面, 单独一个界面, 可以由一个人负责.

  2. 主界面由一个窗口, 一个左侧Tab, 右侧列表构成, 可以一个人负责.

  3. 文件的下载上传历史记录, 涉及到序列化问题, 可以采用SQLite+CoreData, 由客户端处理.

  4. 因为我们有一个golang实现的进程专门用来对接后端的grpc请求还有处理磁盘数据等, 所以文件共享数据的传输, 可以用golang来实现(以下简称golang进程), 非常高效. 同时再计算出传输进度, 方便客户端查询和展示.

到了开发这一步, 第一点由一个同事负责, 其余的都由我负责.

这是因为第一点其实只是把需要共享的数据保存到数据库即可, 不涉及到具体传输; 后面四点, 需要开发者对数据库设计有较好理解, 这个我是比较符合的, 毕竟我做过后端复杂数据库设计, 基础比较好, 文件传输方面, 我对字节流的处理理解深刻一些, 能较好处理文件传输功能. 还有一个很关键的, 我也负责golang进程的开发, 所以很自然我就把剩余的功能都包了, 自己和自己对接效率会提高很多 : )

具体设计

  开始从编码的角度设计程序.  

主界面

主界面由一个独立的Window构成, 左侧是一个TableView控制器(Tab控制器), 用来实现Tab(分类), 右侧也是一个TableView控制器(列表控制器), 用来展示列表信息; 这块没什么好说的, 简单过了.

Tab 控制器

这里左侧的Tab, 每个Tab分类对应一个右侧的列表控制器, 采用模式驱动的方式, 事先定义好对应的tab点击事件, 在点击事件中创建好控制器, 这样左侧的Tab控制器代码就很简单了, 不需要大量if else判断语句. 代码原型如下:

// 创建一个用来存放所有tab信息的驱动模型
var items = [TableCellModelDriverBase]()

// 一个md代表一个tab的所有信息
var md = TableCellModelDriverBase()
items.append(md)

md.disableSelected = true
md.height = 40
md.shouldHighlight = true
md.reuseIdentifier = CellID
// 可设置单独针对某个行的点击事件, 也可以通过代理统一处理
md.click = { [unowned self] md, cell in
guard let cell = cell as? FileShareTabCellView else {
return
}

// 配置"全部文件"这个功能的控制器以及VM, 每一个tab都会分配一个独立的控制器以及VM.
// 这里让每一个md的散列值和控制器关联, 后续就算tab会变动, 也不会丢失控制器的关系.
let detailVC = self._makeDetail(for: md)
if detailVC.viewModel == nil {
detailVC.viewModel = FileShareDetailReceiveVM()
detailVC.viewModel.reloadTarget = detailVC
}

self._changeDetailViewController(to: detailVC)

}
md.renderBlock = { (cell: NSTableCellView) in
guard let cell = cell as? FileShareTabCellView else {
return
}

cell.norImg = NSImage(named: "all_file")
cell.selcImg = NSImage(named: "all_file_selc")
cell.textField?.stringValue = "全部文件"

}

// 省略其他md

然后在Tab控制器中, 不再需要大量的判断语句, 而是直接从items数组中取出对应的md模型, 直接使用即可, 这样做可以极大增强代码的聚合度, 修改Tab的顺序, 增加新的Tab也很灵活.

下面代码很容易理解, 简单说, 就是先获取md, 渲染时直接调用md提供的renderBlock闭包, 或者点击时调用click闭包即可.

extension FileShareTabVC: NSTableViewDelegate {
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
let md = mdDataArr![row]
let view = tableView.makeView(withIdentifier: md.reuseIdentifier, owner: nil) as! NSTableCellView
if let b = md.renderBlock {
b(view)
}

if view.conforms(to: FileShareTabHightlightProtocol.self) {
if self.tableView.selectedRow == row {
(view as! FileShareTabHightlightProtocol).fsHightlight()
} else {
(view as! FileShareTabHightlightProtocol).fsNonHightlight()
}
}
return view
}

func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
let md = mdDataArr![row]
return CGFloat(md.height!)
}

func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? {
let md = mdDataArr![row]
let rowV = FileShareTabRowView()
if !md.shouldHighlight {
rowV.customHighlightStyle = NSTableView.SelectionHighlightStyle.none
}
return rowV
}

func tableView(_ tableView: NSTableView, selectionIndexesForProposedSelection proposedSelectionIndexes: IndexSet) -> IndexSet {
return proposedSelectionIndexes.filteredIndexSet { (idx) -> Bool in
if self.mdDataArr![idx].disableSelected {
return false
}
return true
}
}

func tableViewSelectionDidChange(_ notification: Notification) {
let md = mdDataArr![tableView.selectedRow]
let cell = tableView.view(atColumn: 0, row: tableView.selectedRow, makeIfNecessary: true) as! NSTableCellView
if let click = md.click {
click(md, cell)
}

tabDelegate?.tab_tableView(tableView: tableView, didSelectedRowAt: tableView.selectedRow)
}
}

列表控制器

列表控制器采用MVVM模式设计, 考虑到我们团队其他成员不熟悉swift, 原本打算引入RxSwift的想法就放弃了. 不过不影响MVVM或者说类似MVVM的设计.

考虑到列表控制器主要是分成多种类型, 但是界面基本一致, 所以可以先把界面上变化的元素抽想象出来, 封装到ViewModel中, 最后为每个不同类型的列表控制器设置不同的VM, 这样的编码方式, 也是能较少大量的判断语句, 同时也提高的扩展性, 代码也更加易读. 看一下示例代码:

下面是抽象出来的VM, 涵盖了列表控制器界面所需要的一切信息了

typealias MDClickAction = (_ md: TableCellModelDriverBase, _ cell: NSTableCellView) -> Void
typealias MDRenderBlock = (_ cell: NSTableCellView) -> Void
typealias MakeAndRenderBlock = (_ tv: NSTableView, _ row: Int) -> NSTableCellView

protocol FileShareViewModelProtocol {

var datas: Array<Any> {get set}

var columns: Array<FileShareDetailColumn> {get set}

var emptyString: String {get}

var emptyImage: NSImage {get}

var cleanEnable: Bool {get}

var titleText: String {get}

// 活跃的数据量(一般是未读的, 下载中, 上传中这类)
var activeEntityCount: Int {get}

var reloadTarget: FileShareReloadable? { get set }

func createCellRenderMap() -> Dictionary<NSUserInterfaceItemIdentifier, MakeAndRenderBlock>

// 加载数据
func loadData()

// 清除记录
func cleanData()
}

其中createCellRenderMap方法, 返回一个字典, key是对应的column标记, value就是如何渲染该列的cell, 应该很好理解. 接着根据列表的总类型, 定义多个实现FileShareViewModelProtocol协议的ViewModel, 赋值给列表控制器即可.

看一下列表控制器的代码, 很抽象, 不会涉及太多细节, 主要是通过实现了FileShareViewModelProtocol的VM来决定界面最终展示的样子.

protocol FileShareReloadable: AnyObject {
func reloadData()
}

class FileShareDetailVC: NSViewController {

var viewModel: FileShareViewModelProtocol!

@IBOutlet weak var tableView: NSTableView!
@IBOutlet var topView: NSView!
@IBOutlet var bottomView: NSView!
@IBOutlet weak var titleLabel: NSTextField!

@IBOutlet weak var cleanBtn: NSButton!

var emptyView: TEBrowserEmptyView!


@IBAction func showRecordAction(_ sender: Any) {

}


@IBAction func clearAction(_ sender: NSButton) {
self.viewModel.cleanData()
}

@IBAction func recordAction(_ sender: NSButton) {
NSLog("点击全部记录")
}

override func viewDidLoad() {
super.viewDidLoad()
// Do view setup here.

for col in self.viewModel!.columns {
self.tableView.addTableColumn(col)
}
}

override func viewWillAppear() {
self.viewModel.loadData()
}

}

extension FileShareDetailVC: NSTableViewDelegate {
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
let cell = self.viewModel.createCellRenderMap()[tableColumn!.identifier]!(tableView, row)
return cell
}
}

extension FileShareDetailVC: NSTableViewDataSource {
func numberOfRows(in tableView: NSTableView) -> Int {
if self.viewModel?.datas.count == 0 {
if self.emptyView == nil {
// 显示空视图
}
}else {
self.emptyView?.removeFromSuperview()
self.emptyView = nil
}
return self.viewModel.datas.count
}
}

extension FileShareDetailVC: FileShareReloadable {
func reloadData() {
self.tableView.reloadData()
}
}

数据库设计

  数据库设计必须结合后端接口和界面, 这里只讨论设计的思路, 不涉及详细字段设计.   

接收表, 下载表

主界面需要显示全部接收数据, 所以需要一个用来记录接收的文件信息表. 每一个文件都可以产生最多一个下载记录或者下载进行中的记录. 这里可以在接收表中设计一个关联属性, 一对一关联到下载表, 这样下载表就只负责记录下载状态和进度.

上图是接收表, 字段可以忽略不管, 只看最底部的downloadInfo关联属性. 其中CoreData可以对关系的删除规则进行设定.

接收表 <–> 下载表, downloadInfo 关系为1对1, 这个关联属性设置成Cascade时,

  1. 删除ReceiveFileEntity时, sqlite中对应的DownloadEntity才会被删除.

  2. 删除DownloadEntity中的数据时, ReceiveFileEntity中对应的DownloadEntity的id字段会被设置成null

设置成Nullify时:

  1. 删除ReceiveFileEntity时, DownloadEntity并不会被自动删除.

  2. 删除DownloadEntity中的数据时, ReceiveFileEntity中对应的DownloadEntity的id字段同样会被设置成null

发送订单表, 文件发送表, 用户信息表

这三个表的出现, 主要是结合后端接口来的. 每个用户都可以共享多个文件给其他人, 所以发送表用来保存每一个需要上传的文件的具体信息. 每次批量共享的文件后端都把他们当作同一个订单处理, 所以需要一个订单表, 至于用户表, 是因为每个订单都可以发送给多个用户, 最后他们的关系如下:

订单表 <–>> 文件发送表, 一对多
订单表 <–>> 用户信息表, 一对多

这个关系应该是很好理解, 其中界面显示的就是文件发送表和用户表的数据, 订单表不会展示到界面上

订单表中的关联属性有send_files, 关联着文件发送表, 删除规则是NO Action, 意味着删除订单表或者文件发送表中的数据互不影响;

订单表中的关联属性dst_users, 关联着用户信息表, 删除规则是 Cascade, 意味着删除订单数据会清空关联的用户信息. 因为功能上用户无法直接操作用户信息表, 所以不用担心用户信息被删除导致订单表中的关联属性被设置成null.

数据库管理模块

这个模块, 简单说就是对数据库做一个增删查改, 独立封装一个类, 提供给界面, 文件传输模块使用.

文件传输

  文件传输的编程思想, 在很多场合都可以遇见. 比如我以前做过的虚拟币重提币服务 :)

控制并发数

思路是这样, 先定义一个传输的并发数量(令牌). 每次要开始执行传输任务之前, 先比较当前任务数量是否超过并发最大值, 不超过时, 当前数量+1, 开始任务(取得令牌, 进入下一步). 这个过程需要在一个串行队列中进行, 这样就不需要对当前数量这个变量加锁.

启动传输

获取到令牌时, 从数据库中找到所有等待执行的任务, 取出第一个任务, 先标记任务开始, 然后再执行任务. 这样做是因为开始传输的动作是一个联网动作, 异步执行的, 所以先设置任务开始, 再开始, 后续失败则再修改成失败即可. 这样做可以省去事务, 方便编码.

golang进程收到任务启动的请求之后, 根据任务偏移量, 路径等参数, 决定如何传输数据已经存放到磁盘里. 这里有个技巧, 客户端启动任务时, 任务文件名可以添加一个.号, 这样在mac系统上表示隐藏文件, 这样下载过程的文件就不会被用户看到, 避免用户误删导致其他逻辑错误. 等下载完成之后, golang进程再把文件名修改成正常名字, 客户端再从golang进程获取文件的最新信息, 包括传输状态, 偏移量, 文件路径等.

传输进度获取

传输进度的获取, 主要是看文件传输的方式, 我们使用golang进程, 采用GRPC的方式, 每次上传或者下载32K的字节, 分多次完整文件的传输, 所以进度的获取采用的是客户端轮询golang进程的方式, 一秒查一次进度并更新到数据库, 通知相关的观察者更新数据. golang进程则开启多线程, 负责数据传输的同时, 把最新进度更新, 按照任务id存入散列表里, 方便UI进程获取数据.

断点续传

文件传输必不可少的功能就是断点续传, 这个不难, 主要是根据当前已经上传或者下载到的文件偏移位置, 从最后的字节处开始继续完成任务即可. 当然实际编码肯定会涉及到文件的sha1值等的计算, 防止文件传输一半就被修改了, 出现审核的文件和最终共享出去的文件不一致的安全问题.

具体的编程细节就不多说了, 包括文件的校验等, 应该都是比较规范的流程, 就不多说了.

传输状态通知

  状态通知, 其实是为了解藕传输模块和UI功能. 思路不复杂, UI实现订阅好相关的传输事件, 比如新增接收到的文件, 下载上传任务的启动, 进度等, 这样每当传输模块有动作了, 就会更新到UI上, 这样代码结构就非常清晰了.

总结

  本文百忙(百懒)之中抽空写出来的, 具体的编码细节就不完全提供了. 讲的主要是思路, 代码太多看起来也烦人. 其实我是原本就是做iOS的, 但是这次负责这个模块, 有点感触, 那就是技术基本都是通用的, 一开始对新的API之类的可能不熟悉, 但是这个都不是问题, 问题在自己能否利用基础理论知识, 快速掌握一门新技术, 这样不管是做Mac开发也好, iOS开发也好, 后台开发也好, 基本功练好, 都不是问题.

这次客户端我采用Swift5.0开发, 一方面是自己OC写太多了怕更不上时代, 另一方面也是因为Swift才是后续苹果相关产品开发的主流. 项目其他模块用的OC, 文件共享用的Swift, 混合开发并没有什么大问题, 这方面苹果处理的很好.

文章目录
  1. 1. 文档更新说明
  2. 2. 前言
  3. 3. 做什么?
  4. 4. 思考分工
  5. 5. 具体设计
    1. 5.1. 主界面
      1. 5.1.1. Tab 控制器
      2. 5.1.2. 列表控制器
    2. 5.2. 数据库设计
      1. 5.2.1. 接收表, 下载表
      2. 5.2.2. 发送订单表, 文件发送表, 用户信息表
      3. 5.2.3. 数据库管理模块
    3. 5.3. 文件传输
      1. 5.3.1. 控制并发数
      2. 5.3.2. 启动传输
      3. 5.3.3. 传输进度获取
      4. 5.3.4. 断点续传
    4. 5.4. 传输状态通知
  6. 6. 总结