🌟

(Swift)DispatchSourceを利用してファイルの作成・名前変更・削除を監視する

2022/01/16に公開

はじめに

以下の環境で動作確認しました。

  • Xcode 13.2.1 (13C100)
  • iOS 15.1

実装例

DispatchSourceを利用することでファイルの作成・名前変更・削除イベントを監視できます。ここでは例としてDocumentsディレクトリ内のファイル作成・名前変更・削除を監視する実装例を示します。

実装例はSwiftUIを利用しています。Xcodeを開いて新規プロジェクトを作成したら、ContentView.swiftを以下のコードで置き換えてください。

import SwiftUI

protocol FileWatcherDelegate {
  var targetURL: URL { get }
  func onChange()
  func onCancel()
}

class FileWatcher {
  var delegate: FileWatcherDelegate? = nil

  private let queue = DispatchQueue.global(qos: .default)

  private var fileDescriptor: Int32 = -1
  private var source: DispatchSourceFileSystemObject? = nil

  deinit {
    self.stop()
  }
  func stop() {
    if self.source != nil {
      self.source!.cancel()
      self.source = nil
    }
    if self.fileDescriptor < 0 {
      close(self.fileDescriptor)
      self.fileDescriptor = -1
    }
  }
  func start() {
    if self.source != nil {
      return
    }
    guard let url = self.delegate?.targetURL else {
      fatalError("failed to read targetURL property")
    }

    let fileDescriptor = open(url.path, O_EVTONLY)
    let source = DispatchSource.makeFileSystemObjectSource(
      fileDescriptor: fileDescriptor, eventMask: .write, queue: self.queue)

    source.setEventHandler {
      if self.delegate != nil {
        self.delegate!.onChange()
      }
    }
    source.setCancelHandler {
      if self.delegate != nil {
        self.delegate!.onCancel()
      }
    }
    if self.delegate != nil {
      self.delegate!.onChange()
    }

    source.activate()

    self.fileDescriptor = fileDescriptor
    self.source = source
  }
}

class FileList: ObservableObject, FileWatcherDelegate {
  @Published var urls: [URL] = []

  private let url: URL

  var targetURL: URL {
    return self.url
  }

  init(url: URL) {
    self.url = url
  }
  func onChange() {
    guard
      let urls =
        try? FileManager.default.contentsOfDirectory(
          at: self.targetURL, includingPropertiesForKeys: nil,
          options: .skipsSubdirectoryDescendants)
    else {
      return
    }

    DispatchQueue.main.async {
      self.urls = urls
    }
  }
  func onCancel() {
    DispatchQueue.main.async {
      self.urls = []
    }
  }
}

extension URL: Identifiable {
  public var id: String {
    return self.absoluteString
  }
}

struct ContentView: View {
  @ObservedObject var fileList: FileList

  let watcher = FileWatcher()

  var body: some View {
    VStack {
      List(self.fileList.urls) { url in
        Text(url.lastPathComponent)
      }
      Spacer()
      HStack {
        Button("Start") {
          self.watcher.start()
        }
        Button("Stop") {
          self.watcher.stop()
        }
        Button("Create") {
          self.create()
        }
        Button("Remove") {
          self.remove()
        }
      }
    }
  }
  init() {
    guard
      let documentURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
    else {
      fatalError("failed to get Document URL")
    }

    self.fileList = FileList(url: documentURL)
    self.watcher.delegate = self.fileList
  }
  func create() {
    let text = "Hello, World!"
    let n = Int.random(in: 0..<1_000_000)
    let name = "text-\(n).txt"
    let url = self.fileList.targetURL.appendingPathComponent(name)

    do {
      try text.write(to: url, atomically: true, encoding: .utf8)
    } catch {
      fatalError("failed to create file: \(url): \(error)")
    }
  }
  func remove() {
    for url in self.fileList.urls {
      do {
        try FileManager.default.removeItem(at: url)
      } catch {
        fatalError("failed to remove: \(error)")
      }
    }
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView()
  }
}

実装の肝はDispatchSource.makeFileSystemObjectSource(fileDescriptor: fileDescriptor, eventMask: .write, queue: self.queue)です。

まず、ファイルディスクリプタにはopen("/path/to/file", O_EVTONLY)で取得した値を設定する必要があります。iOS 14以降であればCのopen関数ではなくFileDescriptor型を利用して実装することも可能です。

イベントマスクについては.writeという名前に反して作成だけではなく名前の変更や削除イベントも監視することができます。詳しくはDispatchSourceのAPIドキュメントを参照してください。

試運転

実装したアプリの使い方を説明します。

  • ファイルの一覧は画面の上半分に表示されます。
  • Startボタンを押すと監視が始まります。
  • Createボタンを押すとランダムな名前の*.txtファイルが作成されます。
  • Removeボタンを押すと監視対象のディレクトリ内のテキストファイルをすべて削除します。
  • Stopボタンを押すと監視が終ります。

アプリが起動したら、まずはStartボタンを押してください。その後、Createボタンを押すと、ファイルが作成されると同時にファイル一覧が更新されます。

さらに、iOS組み込みのファイルアプリを開いて、ファイルの名前変更や削除を試してみてください。ファイルアプリの変更が即座に反映されます。

なお、ファイルアプリからアプリ内のDocumentsディレクトリが参照できるように変更するにはInfo.plistの設定が必要になります。詳細については以下の記事をご覧ください。

参考資料

  1. Detecting changes to a folder in iOS using Swift
  2. DispatchSource: Detecting changes in files and folders in Swift
  3. How to detect changes in Documents… | Apple Developer Forums

Discussion