socket 开发的要点很多,包括:
- 状态同步
socket 建立连接,连接起码两台设备,设备间状态的同步
- 线程处理。
socket 通常大量涉及线程操作,为不卡主线程,要用 background 线程处理网络资源。
马上要用的网络资源,对应的线程,优先级高。不急的,优先级低
可以说,不并发,不异步,不 socket
- 文件处理和媒体资源处理。
例如: 听歌是需求,媒体资源处理。
先要有歌曲文件,tcp 传输,文件处理, 内存与硬盘间的 I/O
socket 构成简述
最简单的连接含两个端,每个端有自己的 socket 套接字, 一个 socket 发起连接, 一个收听连接
譬如打电话。通话,是网络连接。各自的手机,是双方的 scoket.
- 连哪里
Address 地址和Ports 端口号
每一个 scoket 都有自己的地址,地址分为两部分,主机地址和端口号
scoket 地址,拿现实地址,打比方,
主机地址,就是小区哪一栋楼, 端口号,就是房间地址
主机地址,一般机器的 IP 地址
- 怎么连
Protocol, 协议,本文用 tcp
Stream, 数据流。tcp 是连续的数据流,本文自己造了个 header
- 连了之后,就发数据
本文不侧重线程处理,简单 tcp 发几个包玩,有些文件 I/O 和音频播放
使用 swift 和 GCDAsyncSocket ( Objective - C 库 )
通过开发功能,讲 socket
开发功能一: mac 端修改手机应用的 UserDefaults.
iOS 的持久化策略挺多的,小数据放入 UserDefaults,大家都喜欢。
免费的 Woodpecker , 大都也喜欢。
Woodpecker 的 pro 功能,有一条是修改 UserDefaults 条目,这里简单仿一下
第一步,要有连接
Mac 端, 发起 socket 连接
发起一个 socket 和一个 NetService.
NetService 属于 Bonjour 框架,Bonjour是 苹果设备在 LAN(局域网)中寻找服务的一个主要方法。
protocol HostViewCtrlDelegate: class{
func didHostTask(c controller: HostCtrl, On socket: GCDAsyncSocket)
}
class HostCtrl: NSViewController {
weak var delegate: HostViewCtrlDelegate?
var service: NetService?
var socket: GCDAsyncSocket?
override func viewDidLoad() {
super.viewDidLoad()
startBroadcast()
}
// 第一步,开启
func startBroadcast(){
// Initialize GCDAsyncSocket
socket = GCDAsyncSocket(delegate: self, delegateQueue: DispatchQueue.main)
do {
try socket?.accept(onPort: 0)
service = NetService(domain: "local.", type: "_deng._tcp.", name: "", port: Int32(socket?.localPort ?? 0))
service?.delegate = self
service?.publish()
} catch{
print("Unable to create socket. Error \(error) with user info .")
}
}
// 第 3 步,收到后,清除
func endBroadcast(){
socket?.setDelegate(nil, delegateQueue: nil)
socket = nil
service?.delegate = nil
service = nil
}
}
extension HostCtrl: GCDAsyncSocketDelegate{
// 第二步,收到
func socket(_ sock: GCDAsyncSocket, didAcceptNewSocket newSocket: GCDAsyncSocket) {
delegate?.didHostTask(c: self, On: newSocket)
endBroadcast()
dismiss()
}
}
手机端,加入 socket 连接
去搜索服务
使用 NetServiceBrowser , 去搜索服务
调用 searchForServices 方法,
可看出手机端这一步的 domain 和 type, 与上一步 mac 端设置的一致
class JoinListCtrl: UITableViewController{
var services = [NetService]()
var socket: GCDAsyncSocket?
var serviceBrowser: NetServiceBrowser?
weak var delegate: JoinListCtrlDelegate?
var hostName: String?
override func viewDidLoad() {
super.viewDidLoad()
startBrowsing()
}
func startBrowsing(){
services = []
// Initialize Service Browser
serviceBrowser = NetServiceBrowser()
// Configure Service Browser
serviceBrowser?.delegate = self
serviceBrowser?.searchForServices(ofType: "_deng._tcp.", inDomain: "local.")
}
}
搜索到了服务,就刷新列表,
下图可看出,上一步的 net service 设备 mac
选中列表上的服务栏,就去解析,和 socket 连接
完成搜索 4 步走,
- 第一步,netService 的代理方法
func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool),
找到服务,刷新列表
- 第 2 步, tableView 的代理方法
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath),
去 netService 的解析, resolve(withTimeout:
- 第 3 步,netService 的代理方法, 解析到了,
func netServiceDidResolveAddress(_ sender: NetService),
解析到了,去连接 socket, connectWith(service:
- 第 4 步,走
GCDAsyncSocket的代理方法
func socket(_ sock: GCDAsyncSocket, didConnectToHost host: String, port: UInt16)
socket 连上了
protocol JoinListCtrlDelegate: class{
func didJoinTask(c controller: JoinListCtrl, on socket: GCDAsyncSocket, host name: String)
}
class JoinListCtrl: UITableViewController{
func stopBrowsing(){
serviceBrowser?.stop()
serviceBrowser?.delegate = nil
serviceBrowser = nil
}
func connectWith(service s: NetService) -> Bool{
var isConnected = false
// Copy Service Addresses
guard let addresses = s.addresses else{
return false
}
if let ss = socket, ss.isConnected{
isConnected = true
}
if isConnected == false{
// Initialize Socket
socket = GCDAsyncSocket(delegate: self, delegateQueue: DispatchQueue.main)
// Connect
while isConnected == false, addresses.count > 0 {
let address = addresses[0]
do {
if let sss = socket{
try sss.connect(toAddress: address)
// 结果 bool ,
// 就是 ok,
// 不 ok, 顺带 error 信息
isConnected = true
}
} catch {
print("Unable to connect to address. Error \(error) with user info ")
}
}
}
return isConnected;
}
}
extension JoinListCtrl: NetServiceDelegate, NetServiceBrowserDelegate{
// 第一步,找到服务,刷新列表
func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
// Update Services
services.append(service)
if !moreComing{
// Sort Services
services.sort { (lhs, rhs) -> Bool in
lhs.name > rhs.name
}
// Update Table View
tableView.reloadData()
}
}
func netServiceBrowser(_ browser: NetServiceBrowser, didRemove service: NetService, moreComing: Bool) {
// Update Services
if let index = services.firstIndex(where: { (s) -> Bool in
s == service
}){
services.remove(at: index)
}
if !moreComing{
// Update Table View
tableView.reloadData()
}
}
// 第 3 步, 解析到了,去连接 socket
func netServiceDidResolveAddress(_ sender: NetService) {
// Connect With Service
if connectWith(service: sender){
print("Did Connect with Service: domain(\(sender.domain)) type(\(sender.type)) name(\(sender.name)) port(\(sender.port)")
}
else{
print("Unable to Connect with Service: domain(\(sender.domain)) type(\(sender.type)) name(\(sender.name)) port(\(sender.port)")
}
}
}
extension JoinListCtrl: GCDAsyncSocketDelegate{
// 第 4 步, socket 连上了
func socket(_ sock: GCDAsyncSocket, didConnectToHost host: String, port: UInt16) {
print("Socket Did Connect to Host: \(host) Port: \(port)")
// Notify Delegate
if let n = hostName{
delegate?.didJoinTask(c: self, on: sock, host: n)
}
// Stop Browsing
stopBrowsing()
// Dismiss View Controller
dismiss(animated: true) {
}
}
}
extension JoinListCtrl{
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let kServiceCell = "ServiceCell"
var cell = tableView.dequeueReusableCell(withIdentifier: kServiceCell)
if cell == nil{
cell = UITableViewCell(style: .default, reuseIdentifier: kServiceCell)
}
let service = services[indexPath.row]
cell?.textLabel?.text = service.name
return cell ?? UITableViewCell()
}
// 第 2 步, 去 netService 的解析
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
// Fetch Service
let service = services[indexPath.row]
hostName = service.name
// Resolve Service
service.delegate = self
// 点击服务
service.resolve(withTimeout: 30.0)
}
}
第二步:数据传输,与修改生效
UserUserfault 在手机中,是一个 plist 文件,Library 的子文件夹下。
路径是 file:///var/mobile/Containers/Data/Application/0A888A58-0143-44D8-B260-7C4FFFBFE1DB/Library/Preferences/com.111.222.socketD.plist
com.111.222.socketD.plist 是 bundleIdentifier
流程是:
手机通过 socket 连接,把数据发送给 mac 应用,mac app 保存下来,
mac 桌面查看与修改,就方便多了, mac 上改好后,
mac app 把数据传给手机端,手机端上面改后的数据生效
手机端发数据,
点击发送数据
var taskAdmin: TaskManager?
@IBAction func sendData(_ sender: UIButton) {
if let src = URL.prefer, FileManager.default.fileExists(atPath: src.path), let data = NSData(contentsOf: src){
taskAdmin?.send(packet: data)
}
}
封装下 UserDefaults 的 plist 文件的地址
extension URL{
static var prefer: URL?{
var preferURL: URL? = nil
if let fileName = Bundle.main.bundleIdentifier, let library = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first{
let preferences = library.appendingPathComponent("Preferences")
preferURL = preferences.appendingPathComponent(fileName).appendingPathExtension("plist")
}
return preferURL
}
TaskManager, socket 发送数据、接收数据和解析数据的统一管理者
发包部分
TCP 连接,只能传输二进制的包
本文发包的方式:
HTTP 请求 request 和响应 response,里面的 header 字段丰富。
本文的 tcp 包,分为 head 和 body 两部分,
header 只包含 body 的大小 length
数据流 stream 读了 header ,就知道接下来的 body 的大小了
下面的代码中,一个数据块 packet, 先拼接 header 的二进制数据,再拼接 body 的二进制数据
body 是对象,先拿到他的指针 UnsafeRawBufferPointer,代表 bytes,
再指明指针类型,拿到该指针的首地址。
NSMutableData 拼接数据,即取首地址,和所占内存的大小
class TaskManager : NSObject{
var socket: GCDAsyncSocket
func send(packet data: NSData){
// Send Packet
let packet = Package(package: Data(referencing: data), type: PacketType.sendData)
send(with: packet)
}
fileprivate
func send(with packet: Package){
// packet to buffer
// 包,到 缓冲
// Encode Packet Data
do {
let encoded = try NSKeyedArchiver.archivedData(withRootObject: packet, requiringSecureCoding: false)
// Initialize Buffer
let buffer = NSMutableData()
// buffer = header + packet
// Fill Buffer
var headerLength = encoded.count
buffer.append(&headerLength, length: MemoryLayout<UInt64>.size)
encoded.withUnsafeBytes { (p) in
let bufferPointer = p.bindMemory(to: UInt8.self)
if let address = bufferPointer.baseAddress{
buffer.append(address, length: headerLength)
}
}
// Write Buffer
if let d = buffer.copy() as? Data{
socket.write(d, withTimeout: -1.0, tag: 0)
}
} catch {
print(error)
}
}
}
模型的样子,和模型的编码、归档部分
发 plist, 只需要一个属性,let data: Data?
其余的属性,第二个功能要用
class Package: NSObject{
let data: Data?
let type: PacketType
let word: String?
let name: String?
let toTheEnd: Bool
let kind: Int
init(package info: Data, type t: PacketType){
data = info
type = t
word = nil
name = nil
toTheEnd = false
kind = 1
super.init()
}
}
extension Package: NSCoding, NSSecureCoding{
func encode(with coder: NSCoder) {
coder.encode(data, forKey: PacketKey.data)
coder.encode(type.rawValue, forKey: PacketKey.type)
coder.encode(word, forKey: PacketKey.word)
coder.encode(name, forKey: PacketKey.name)
coder.encode(toTheEnd, forKey: PacketKey.toTheEnd)
coder.encode(kind, forKey: PacketKey.kind)
}
static var supportsSecureCoding: Bool {
true
}
}
mac 端收包部分:
mac 这边收到的 UserDefaults 的 plist 数据,存放的路径是 /Users/ge/Downloads/socketPlay/prefer.plist
(/Users/{ 你的电脑 }/Downloads/socketPlay/prefer.plist)
这边 mac 应用,把数据放在了下载文件夹,注意先开通下文件夹访问权限
- 先收到数据,去解析
GCDAsyncSocket 的代理方法,
func socket(_ sock: GCDAsyncSocket, didRead data: Data, withTag tag: Int),
处理收到数据的逻辑
解析数据,分两部分解析,
先解析 header, 再解析 body.
解析 body 有 30 秒,
不知道下一个 header 什么时候来,Timeout 使用 -1, socket 存在,就一直等下去
GCDAsyncSocket 的 tag
代理方法 func socket(_ sock: GCDAsyncSocket, didRead data: Data, withTag tag: Int) 的 tag,
和 readData(toLength:, withTimeout: , tag:) 的 tag,是同一个
- tag 只能自己本地用用,简单标记,不能透传给对方
另一设备的 socket tag 与本设备 socket tag ,无关
protocol TaskManagerProxy: class{
func didReceive(packet data: Data?)
func didStartNewTask()
}
class TaskManager: NSObject{
weak var delegate: TaskManagerProxy?
}
extension TaskManager: GCDAsyncSocketDelegate{
func socket(_ sock: GCDAsyncSocket, didRead data: Data, withTag tag: Int) {
switch tag{
case 0:
let bodyLength = parse(header: data)
socket.readData(toLength: bodyLength, withTimeout: -1.0, tag: 1)
case 1:
parse(body: data)
socket.readData(toLength: UInt(MemoryLayout<UInt64>.size), withTimeout: -1.0, tag: 0)
default:
()
}
}
func parse(header data: Data) -> UInt{
var headerLength: UInt = 0
NSData(data: data).getBytes(&headerLength, length: MemoryLayout<UInt>.size)
return headerLength
}
func parse(body data: Data){
do {
NSKeyedUnarchiver.setClass(Package.self, forClassName: "socketG.Package")
NSKeyedUnarchiver.setClass(Package.self, forClassName: "socketD.Package")
NSKeyedUnarchiver.setClass(Package.self, forClassName: "Package")
let packet = try NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSDictionary.self, Package.self], from: data) as! Package
switch packet.type {
case .start:
delegate?.didStartNewTask()
case .sendData:
delegate?.didReceive(packet: packet.data)
default:
()
}
} catch {
print(error)
}
}
}
}
解码的时候,要注意:
Swift 的类,自带工程名,
Mac 应用项目的类,去解析 iOS 应用项目的类,会报错。
-[NSKeyedUnarchiver decodeObjectForKey:]: cannot decode object of class (socketD.Package) for key (root) because no class named "socketD.Package" was found; the class needs to be defined in source code or linked in from a library (ensure the class is part of the correct target).
需要 NSKeyedUnarchiver 做一个迁移,
NSKeyedUnarchiver.setClass(Package.self, forClassName: "socketD.Package")
- 模型的解码、解档部分
class Package: NSObject{
let data: Data?
let type: PacketType
let word: String?
let name: String?
let toTheEnd: Bool
let kind: Int
required init?(coder: NSCoder) {
data = coder.decodeObject(forKey: PacketKey.data) as? Data
type = PacketType(rawValue: coder.decodeInteger(forKey: PacketKey.type)) ?? PacketType.default
word = coder.decodeObject(forKey: PacketKey.word) as? String
name = coder.decodeObject(forKey: PacketKey.name) as? String
toTheEnd = coder.decodeBool(forKey: PacketKey.toTheEnd)
kind = coder.decodeInteger(forKey: PacketKey.kind)
}
}
数据保存下来
extension ViewController: TaskManagerProxy{
func didReceive(packet data: Data?){
guard let datum = data else {
return
}
do {
let dict = try PropertyListSerialization.propertyList(from: datum, format: nil) as! [String: Any]
if let url = URL.src{
if FileManager.default.fileExists(atPath: url.absoluteString){
try FileManager.default.removeItem(atPath: url.absoluteString)
}
NSDictionary(dictionary: dict).write(toFile: url.absoluteString, atomically: true)
}
} catch {
print(error)
}
}
mac 上, 封装的文件路径
具体路径, /Users/{ 你的电脑 }/Downloads/socketPlay/prefer.plist
extension URL{
static var dir: URL?{
var pathURL: URL? = nil
let path = "/Users/\(NSUserName())/Downloads/socketPlay"
if let url = URL(string: path){
if FileManager.default.fileExists(atPath: path) == false{
do {
try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil)
} catch{
print(error)
}
}
pathURL = url
}
return pathURL
}
static var src: URL?{
var pathURL: URL? = nil
if let url = URL.dir{
pathURL = url.appendingPathComponent("prefer.plist")
}
return pathURL
}
mac 端上修改:
点击
@IBAction func openFile(_ sender: NSButton){
if let url = URL.dir{
NSWorkspace.shared.openFile(url.absoluteString)
}
}
找到了
自由修改
mac 端上改好后,发送给手机端,使变更后的数据生效
@IBAction func sendData(_ sender: NSButton) {
if let url = URL.src{
if FileManager.default.fileExists(atPath: url.absoluteString), let data = NSData(contentsOfFile: url.absoluteString){
taskAdmin?.send(packet: Data(referencing: data))
}
}
}
Mac 端 socket 发送数据,与手机端部分 socket 的代码,完全一致
不再赘述。
小结: 写socket, 可见协调的重要性。
客户端和服务端合起来,才是一个完整的应用。 数据模型层,两端共享。数据从一端,流向另一端,是通用的
手机端部分,收到数据,让更改生效
手机端 socket 收到数据并解析,与 mac 部分的代码,一致
不再赘述。
Library 中 Preferences 下的 UserDefaults 对应的 plist 文件,系统有保护,
不能够删除文件,重写写入。
简单的生效途径,是遍历修改
extension ViewController: TaskManagerProxy{
func didReceive(packet data: Data?){
guard let datum = data else {
return
}
do {
if let dict = try PropertyListSerialization.propertyList(from: datum, format: nil) as? [String: Any]{
for pair in dict{
UserDefaults.standard.set(pair.value, forKey: pair.key)
}
}
} catch {
print(error)
}
refreshFlag()
}
验证:本 demo 的例子是,改 age
启动应用,默认写入,
上一段中可见,收到该 plist 后,又刷新了一次,调用了 refreshFlag()
override func viewDidLoad() {
super.viewDidLoad()
// test data
UserSetting.std.age = 80
refreshFlag()
}
func refreshFlag(){
verifyLabel.text = "验: 年 \(UserSetting.std.age)"
}
UserDefaults 封装部分
struct InfoKeys {
static let age = "_age_"
}
struct UserSetting {
static var std = UserSetting()
var age: Int{
get {
if let year = UserDefaults.standard.value(forKey: InfoKeys.age) as? Int{
return year
}
return 0
}
set(newVal){
UserDefaults.standard.set(newVal, forKey: InfoKeys.age)
}
}
}
小结: GCDAsyncSocket 封装的很好,socket 相关一般处理:
先连上,然后不停的写 , write 操作,不停发包,
其代理里面,不停读回数据,read, 就完
此外:
-
心跳就是个计时器,确保连接上,不停调用 connect, 不停连
-
断点续传,就是把进度本地化,记录一下
-
一个包大了,要发送的数据大于 TCP 发送缓冲区的剩余空间大小,将会发生拆包
Woodpecker 强大,UI 很好,同时能够支持多个 socket 连接,mac app 作为 hud 管理。
本开发,设备不多,暂无冲动仿一下
开发功能 2: mac 端发包,手机端消费。做一下拆包
功能点, mac 端有 m4a 的歌曲资源,发给手机端听歌
这里做一下拆包。上一个功能,把数据塞一个包,完事。
一首歌,一般 3M ~ 5M, 不算是微小文件。可以拆为多个包,逐个发送,特征是有序,适合 TCP, 没采用 UDP
本 demo 拆的包,一个包通常 200 kb
本 demo 采用的歌曲,可以 100 M
使用 socket 发,无线无繁琐
Mac 端,发送歌曲资源,给手机端
点击看资源
就是把上文提过的文件夹里面的资源,都拉出来,筛选出含 "mp3" 和 “m4a” 的文件,刷新列表
class MusicBroswer: NSViewController {
var files = [URL]()
override func viewWillAppear() {
super.viewWillAppear()
files.removeAll()
if let src = URL.dir{
do {
let properties: [URLResourceKey] = [ URLResourceKey.localizedNameKey, URLResourceKey.creationDateKey, URLResourceKey.localizedTypeDescriptionKey]
let paths = try FileManager.default.contentsOfDirectory(at: src, includingPropertiesForKeys: properties, options: [FileManager.DirectoryEnumerationOptions.skipsHiddenFiles])
for url in paths{
let isDirectory = (try url.resourceValues(forKeys: [.isDirectoryKey])).isDirectory ?? false
let musicExtern = ["mp3", "m4a"]
if isDirectory == false, musicExtern.contains(url.pathExtension){
files.append(url)
}
}
table.reloadData()
} catch let error{
print("error: \(error.localizedDescription)")
}
}
}
点击即发送
歌曲资源,自己下载的
一个文件,拆分为多个包,自然可以手撕 Data, NSData
这里的文件 I/O, 采用 FileHandle
FileHandle 读数据,生效三步:
-
拿 path ,去初始化,
-
读到哪里,seek 一个 offset
-
再 read 指定长度的 Data,更新第二步的 offset,接着读。
读完以后,就关掉,调用 close() 方法.
苹果封装的,很易用
发送数据,要做的
FileHandle 读了一段,就装个包,发 socket
class TaskManager : NSObject{
func send(file url: URL){
fileAdmin = FileAdminister(url: url)
sendFile()
}
fileprivate
func sendFile(){
let beyond: Bool = fileAdmin?.beyond ?? true
let toTheEnd: Bool = fileAdmin?.tillEnd ?? true
guard beyond == false else {
stopSendingFile()
return
}
if toTheEnd{
fileAdmin?.beyond = true
}
do {
try fileAdmin?.handler?.seek(toOffset: fileAdmin?.offset ?? 0)
fileAdmin?.offsetForward()
var blockLength = fileAdmin?.stride ?? 0
if toTheEnd{
blockLength = fileAdmin?.rest ?? 0
}
guard let body = fileAdmin?.handler?.readData(ofLength: blockLength) else{
return
}
let packet = Package(buffer: body, name: fileAdmin?.name, to: toTheEnd)
let encoded = try NSKeyedArchiver.archivedData(withRootObject: packet, requiringSecureCoding: false)
// Initialize Buffer
let buffer = NSMutableData()
// buffer = header + packet
// Fill Buffer
var headerLength = encoded.count
buffer.append(&headerLength, length: MemoryLayout<UInt64>.size)
encoded.withUnsafeBytes { (p) in
let bufferPointer = p.bindMemory(to: UInt8.self)
if let address = bufferPointer.baseAddress{
buffer.append(address, length: headerLength)
}
}
// Write Buffer
if let d = buffer.copy() as? Data{
socket.write(d, withTimeout: -1.0, tag: Tag.buffer.rawValue)
}
}catch{
print(error)
}
}
封装 FileHandle,包含相关状态处理
封装的 FileAdminister 里面,有两个代表结束的属性,
内部产生一个结束 tillEnd, 外部指定一个结束 beyond
因为内部产生结束后,还剩余一点尾巴数据,没有发,需要 + 1 的记录
struct FileAdminister {
let handler: FileHandle?
let length: UInt64
let name: String
var offset: UInt64
var tillEnd: Bool
var beyond: Bool
let stride: Int
var rest: Int
init(url src: URL) {
handler = FileHandle(forReadingAtPath: src.path)
print(src.path)
assert(FileHandle(forReadingAtPath: src.path) != nil, "handler 初始化出错")
var len = 0
if let data = NSData(contentsOfFile: src.path){
len = data.length
}
length = UInt64(len)
name = src.lastPathComponent
offset = 0
tillEnd = false
beyond = false
stride = 1024 * 200
rest = 0
}
mutating
func offsetForward(){
offset += UInt64(stride)
print("进度:")
print(Double(offset)/Double(length))
print("\n\n")
// 一次 200 k B
if offset >= length{
tillEnd = true
rest = stride - Int(offset - length)
}
}
}
发包,对应的模型
这里需要使用三个属性,
- data , 音频数据块
- name,文件名, 歌曲名
- toTheEnd, 发包发完了没有。如果手机端,知道 mac app 发完了,就把数据存文件
注意,有一个 kind
本文采用大统一模型,就是两端 socket 交流的数据,都采用这一个模型,根据不同的消息类型,去取不同的有效字段。
有不同的方法,这种比较简单
class Package: NSObject{
let data: Data?
let type: PacketType
let word: String?
let name: String?
let toTheEnd: Bool
let kind: Int
init(buffer info: Data, name n: String?, to theEnd: Bool){
data = info
type = PacketType.sendData
word = nil
name = n
toTheEnd = theEnd
kind = 3
super.init()
}
}
连续发包操作:
发了一个包,即完成了一次写入 write, 接着再发一个包,
通过 GCDAsyncSocket 的这个代理方法,func socket(_ sock: GCDAsyncSocket, didWriteDataWithTag tag: Int)
extension TaskManager: GCDAsyncSocketDelegate{
func socket(_ sock: GCDAsyncSocket, didWriteDataWithTag tag: Int) {
if tag == Tag.buffer.rawValue{
sendFile()
}
}
}
手机端收包,并解析,数据保存为文件
收包,与解析的过程,与上文的类似。
下面的代码,多了一个根据 kind 字段, 分流的逻辑
func parse(header data: Data) -> UInt{
var headerLength: UInt = 0
NSData(data: data).getBytes(&headerLength, length: MemoryLayout<UInt>.size)
return headerLength
}
func parse(body data: Data){
do {
NSKeyedUnarchiver.setClass(Package.self, forClassName: "socketG.Package")
NSKeyedUnarchiver.setClass(Package.self, forClassName: "Package")
let packet = try NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSDictionary.self, Package.self], from: data) as! Package
switch packet.kind {
case 1:
switch packet.type {
case .start:
delegate?.didStartNewTask()
case .sendData:
delegate?.didReceive(packet: packet.data)
default:
()
}
case 2:
delegate?.didCome(a: packet.word)
case 3:
delegate?.didReceive(title: packet.name, buffer: packet.data, to: packet.toTheEnd)
default:
()
}
} catch {
print(error)
}
}
FileHandle 写数据,生效三步:
-
拿 path ,去初始化,
-
seek 到结尾,接着写,即拼接数据
-
再 write data,接着第二步的 seek 到结尾,接着拼接数据
写完以后,就关掉,调用 close() 方法.
苹果封装的,易用
class ViewController: UIViewController {
var fileHandlers = [String: FileHandle]()
func didReceive(title name: String?, buffer data: Data?, to theEnd: Bool) {
guard let title = name, let buffer = data else {
return
}
if fileHandlers[title] == nil{
fileHandlers[title] = FileHandle(forWritingAtPath:
title.write)
print(title.write)
print("新建 ", fileHandlers[title]?.description ?? "")
}
do {
fileHandlers[title]?.seekToEndOfFile()
fileHandlers[title]?.write(buffer)
if theEnd{
print("至于结尾")
try fileHandlers[title]?.close()
fileHandlers.removeValue(forKey: title)
}
} catch { print(error) }
}
}
封装的保存到硬盘的路径
FileHandle 初始化要注意,
先创建实际存在的空文件,才能创建对应路径的 FileHandle
extension String {
var write: String{
let path = "\(URL.dir)/\(self)"
if FileManager.default.fileExists(atPath: path) == false{
FileManager.default.createFile(atPath: path, contents: nil, attributes: nil)
}
return path
}
}
另一个选择,接收数据,保存文件,直接用 NSData 手撕,加强理解 I/O 的过程
代码很好理解,
就是没有 NSMutableData,就拿新收到的数据,去创建,
有了,就继续拼接数据。
接收完毕,就写入硬盘,
释放内存。
class ViewController: UIViewController {
var buffers = [String: NSMutableData]()
func didReceive(title name: String?, buffer data: Data?, to theEnd: Bool) {
guard let title = name, let buffer = data else {
return
}
if buffers[title] == nil{
buffers[title] = NSMutableData(data: buffer)
}
else{
buffers[title]?.append(buffer)
}
if theEnd, let file = buffers[title]{
file.write(toFile: "\(URL.dir)/\(title)", atomically: true)
buffers.removeValue(forKey: title)
}
}
}
因为上面的代码,是一块块把数据包拼完,再一次写入。
一首 100 M 的歌曲,占用的内存,就会比较大。
FileHandle ,对内存,更加友好,
100 M 的歌,通过 FileHandle 处理,增加的内存波动在 1 M ~ 5 M
他是多次写入,持有一部分,写入之前一部分。
之前已经写入的,释放掉
Auto Release Pool 标准的应用场景。
使用 NSData, 要对内存更加友好,代码相对繁琐些
具体到项目里面跑,可参见 github 链接 iOS 端 的这个 commit, f7ea5568c4ff04c993e34eb77c2efacc800274d3
手机端播放文件
进入播放界面:
与上面的一样,做的比较简单,就是把 document 文件夹的相关资源,拉出来
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
refreshData()
}
func refreshData(){
files.removeAll()
if let src = URL(string: URL.dir){
do {
let properties: [URLResourceKey] = [ URLResourceKey.localizedNameKey, URLResourceKey.creationDateKey, URLResourceKey.localizedTypeDescriptionKey]
let paths = try FileManager.default.contentsOfDirectory(at: src, includingPropertiesForKeys: properties, options: [FileManager.DirectoryEnumerationOptions.skipsHiddenFiles])
for url in paths{
let isDirectory = (try url.resourceValues(forKeys: [.isDirectoryKey])).isDirectory ?? false
let musicExtern = ["mp3", "m4a", "txt"]
if isDirectory == false, musicExtern.contains(url.pathExtension){
files.append(url)
}
}
tableView.reloadData()
} catch let error{
print("error: \(error.localizedDescription)")
}
}
}
开启播放器:
-
播放器开发,可参见博客:
-
Cocoa App 的简单开发,可参见博客:
Cocoa App 开发进一步,表视图 TableView 、文件管理与拖拽
认识 Cocoa App 的开发:Cocoa Binding 、Window Control 、菜单栏...
- 100 M 的 m4a 歌曲哪里来,从 youtube 上面下载 two steps from hell 之类的歌曲,
用 youtube-dl 下载,
youtube-dl 的安装,可参见博客:
代码:
PS: 有人喜欢正版,很多喜欢免费版,有的有一点 pirate open source 精神