肇鑫的技术博客

业精于勤,荒于嬉

获得Finder拖动来的文件URL

新项目需要获得从Finder拖动过来的文件URL。这篇文档是一篇总结。

根据Drag and Drop Programming Topics,拖放操作有拖动源和拖动目的地。Finder是源,我的App是目的地。

拖动的目的地必须是NSWindowNSView及其子类,我选择ViewController对应的view作为源。

注册UTI文件类型

首先要注册view可以从剪贴板获得的数据类型。由于历史的原因,这部分实际上比较混乱。

// MARK: - Dragging destination
required init?(coder: NSCoder) {
	super.init(coder: coder)
	
	// Dragging destination
	self.register(forDraggedTypes: ["public.file-url", "public.folder"]) // somehow, public.folder isn't used
}

避免使用剪贴板类型

虽然苹果的指南里使用的还是NSFilenamesPboardType,但是实际上在API文档里,这种方式已经是不建议使用的了。

Version-Notes
Pboard types will be deprecated in a future release. In macOS 10.6 and later you should replace any use of pboard types with UTIs, including the constants described in Types for Standard Data (macOS 10.6 and later).
版本注释
剪贴板类型在未来的版本会被反对。在macOS 10.6之后的版本你应该使用UTI替代剪贴板类型,即使是10.6引入的新版的也同样替代。
Types for Standard Data (OS X v10.5 and earlier)

UTI文件类型浅析

那么什么是UTI呢?其实就是苹果使用的文件类型系统。具体的可以看这个表格

几种类型的区别

比如我使用的是["public.file-url", "public.folder"],你可以在上面提到的表格里查到这两个常量。你也可以使用苹果定义的常量kUTTypeFileURLkUTTypeFolder作为替代,但是这两个常量是CFString的格式,所以使用时需要转换为String才可以。

由于历史的原因,苹果为了保证兼容性,在剪贴板的类型里会显示多个类型。比如foo.txt,从Finder拖动时,剪贴板里的类型是这样的。

public.file-url
CorePasteboardFlavorType 0x6675726C
dyn.ah62d4rv4gu8y6y4grf0gn5xbrzw1gydcr7u1e3cytf2gn
NSFilenamesPboardType
dyn.ah62d4rv4gu8yc6durvwwaznwmuuha2pxsvw0e55bsmwca7d3sbwu
Apple URL pasteboard type
com.apple.finder.node

可以看到,”public.file-url“和”Apple URL pasteboard type“是同时存在的。kUTTypeFileURL的值实际上对应的就是"public.file-url"。

(lldb) print kUTTypeFileURL
(CFString) $R0 = 0x00007fffc41bc6d0 {}
(lldb) print kUTTypeFileURL as String
(String) $R1 = "public.file-url"
(lldb) print NSFilenamesPboardType
(String) $R2 = "NSFilenamesPboardType"

拖动到目标时

override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
	let sourceDragMask = sender.draggingSourceOperationMask()
	let pb = sender.draggingPasteboard()
	
	if pb.types?.contains("public.file-url") == true {
		if sourceDragMask.contains(.generic) {
			return .generic
		}
	}
	
	return [] // NSDragOperationNone
}

获得剪切板的数据

override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
    let pb = sender.draggingPasteboard()
    
    if pb.types?.contains("public.file-url") == true {
        let urls = pb.readObjects(forClasses: [NSURL.self]) as! [URL]
        
        if let controller = nextResponder as? ViewController {
            controller.urls = urls
            
            return true
        }
    }
    
    return false
}

NSURL与URL

这里必须使用NSURL而不能使用URL,因为前者实现了剪切板读取协议,而后者没有。

剪切板读取协议

NSPasteboardReading协议。

以下类实现了该协议。由于这个协议继承至NSObjectProtocol,只有NSObjectNSProxy的子类才能实现该协议。
NSAttributedString
NSColor
NSFilePromiseReceiver
NSImage
NSPasteboardItem
NSSound
NSString
NSTextStorage
NSURL

iOS app与watchOS app数据同步

保存的数据库采用realm。手表部分的app具备与iOS端相同的功能。

基础部分

数据保存在用户的Document里。升级app数据不会消失,卸载app数据会消失。

同步算法

以iOS端为核心。watchOS发起同步请求。

首次启动

WCSession的生命周期最为标准。每次WCSessionDelegatesession(_:activationDidCompleteWith:error:)的运行被认为是一次首次启动。

  • iOS:设定需要同步数据库needSyncDatabase = true。在同步数据库完成之前,不会发送新的同步信息。
// MARK: - WatchConnectivity
extension AppDelegate:WCSessionDelegate {
    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
        if transfer?.isTransferring == true { transfer?.cancel() }
        
        needSyncDatabase = true
        transfer = session.transferUserInfo(["needSyncDatabase":needSyncDatabase])
    }
    
    func sessionDidBecomeInactive(_ session: WCSession) {
        
    }
    
    func sessionDidDeactivate(_ session: WCSession) {
        
    }
}
  • watchOS:设定同步数据库,手表端有两种情况。
    • WCSession首次启动时主动同步。
    • 接收到iOS发来的同步请求时同步。
// MARK: -WCSessionDelegate
extension ExtensionDelegate:WCSessionDelegate {
    func shouldSyncDatabase() -> Bool {
        return needSyncDatabase && !isSyncingDatabase
    }
    
    func willSyncDatabase() {
        isSyncingDatabase = true
    }
    
    func didSyncDatabase() {
        needSyncDatabase = false
        isSyncingDatabase = false
    }
    
    func syncDatabase() {
        guard shouldSyncDatabase() else { return }
        willSyncDatabase()
        // sync database
        didSyncDatabase()
    }
    
    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
        syncDatabase()
    }
    
    func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
        if let value = userInfo["needSyncDatabase"] as? Bool {
            needSyncDatabase = value
            syncDatabase()
        }
    }
}

运行中

  • iOS
    • 当数据库改变时,发送需要同步的数据。
    • 接收需要同步的数据,存入数据库。

思路

iOS -> watchOS 同步

iOS开发存储策略

程序沙盒

  • 需要相同代码签名和包名
  • Home目录:NSHomeDirectory()
  • 临时文件目录:NSTemporaryDirectory(),在home目录外,但在沙盒里
  • iOS的沙盒内,包含程序自身

程序组容器目录

  • 需要相同开发团队
  • 位置:~/Library/Group Containers/<application-group-id>
  • 调用:FileManagercontainerURL(forSecurityApplicationGroupIdentifier:)方法

使用策略

  • 需要长期保存的数据存在Home目录
  • 需要共享的数据存在程序组容器目录

思考

单独将数据保存在程序组容器目录是否安全?
**安全。**因为只有相同开发团队的人才能访问。而且可以简化开发。
**不安全。**因为一个app的数据有被其它app删除的风险。另外,是否应该限制这部分数据,使只有需要分享的数据才方到这里。
**结论。**我个人更倾向于安全。因为团队内部的人应该被认为是可信的。但是这么做的确会存在过多分享的问题。因此,我认为还是不要把主数据库放在这边,而是将其作为辅助数据库更好。至于说需要额外处理的代码问题。我相信,从长期看,这部分代码必然是必要的。