// // FileLogHandler.swift // Pods // // Created by 250102 on 2025/5/21. // import Foundation import os.log /// 一个rotatable、robust且efficient的文件日志处理器 public class FileLogHandler { // MARK: - 日志级别枚举 public enum LogLevel: Int, Comparable { case verbose = 0 case debug = 1 case info = 2 case warning = 3 case error = 4 case fatal = 5 public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { return lhs.rawValue < rhs.rawValue } public var prefix: String { switch self { case .verbose: return "V" case .debug: return "D" case .info: return "I" case .warning: return "W" case .error: return "E" case .fatal: return "WTF" } } } // MARK: - 旋转配置结构 public struct RotationConfig { /// 日志文件旋转前的最大大小(字节)(默认:10MB) public var maxFileSize: UInt64 /// 要保留的归档日志文件的最大数量(默认:5) public var maxBackupCount: Int public init(maxFileSize: UInt64 = 10_485_760, // 10MB maxBackupCount: Int = 5) { self.maxFileSize = maxFileSize self.maxBackupCount = maxBackupCount } } // MARK: - 属性 private let fileURL: URL private let rotationConfig: RotationConfig private let dateFormatter: DateFormatter private let minimumLogLevel: LogLevel private let queue: DispatchQueue private let fileManager: FileManager private var fileHandle: FileHandle? // MARK: - 初始化 /// 初始化一个新的FileLogHandler /// - 参数: /// - directory: 日志文件的目录路径 /// - filename: 日志文件的基本名称(不带扩展名) /// - rotationConfig: 日志rotation配置 /// - minimumLogLevel: 要记录的最小日志级别 public init(directory: String? = nil, filename: String = "app.log", rotationConfig: RotationConfig = RotationConfig(), minimumLogLevel: LogLevel = .debug) { self.rotationConfig = rotationConfig self.minimumLogLevel = minimumLogLevel // 创建时间戳的日期格式化器 self.dateFormatter = DateFormatter() self.dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" // 创建调度队列以确保线程安全 self.queue = DispatchQueue(label: "com.fileloghandler.queue", qos: .utility) // 使用文件管理器 self.fileManager = FileManager.default // 确定日志目录 let baseDirectory: URL if let directory = directory { baseDirectory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent("Logs", isDirectory: true).appendingPathComponent(directory, isDirectory: true) } else { // 如果未指定目录,默认为缓存目录 #if os(macOS) let cacheDir = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! #else let cacheDir = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! #endif baseDirectory = cacheDir.appendingPathComponent("Logs", isDirectory: true) } // 如果日志目录不存在,则创建它 let logsDirectory = baseDirectory try? fileManager.createDirectory(at: logsDirectory, withIntermediateDirectories: true, attributes: nil) // 设置文件URL self.fileURL = logsDirectory.appendingPathComponent(filename) // 打开或创建日志文件 self.openLogFile() } deinit { try? fileHandle?.close() } // MARK: - 公共方法 /// 使用指定级别记录消息 /// - 参数: /// - message: 要记录的消息 /// - level: 日志的严重级别 /// - file: 调用日志的文件 /// - function: 调用日志的函数 /// - line: 调用日志的行 public func log(_ tag: String, _ message: String, level: LogLevel = .info) { guard level >= minimumLogLevel else { return } queue.async { [weak self] in guard let self = self else { return } // 格式化日志条目 let timestamp = self.dateFormatter.string(from: Date()) let pid = ProcessInfo.processInfo.processIdentifier let logMessage = "\(timestamp) \(pid) \(level.prefix)/\(tag): \(message)\n" // 写入日志文件 self.writeToFile(logMessage) // 检查是否需要基于文件大小进行rotate self.checkSizeRotation() } } /// 手动触发日志rotate public func rotateLogFile() { queue.async { [weak self] in self?.performRotation() } } // MARK: - 私有方法 private func openLogFile() { do { // 如果文件不存在,则创建它 if !fileManager.fileExists(atPath: fileURL.path) { try "".write(to: fileURL, atomically: true, encoding: .utf8) } // 打开文件以进行写入 fileHandle = try FileHandle(forWritingTo: fileURL) // 移动到文件末尾 fileHandle?.seekToEndOfFile() } catch { print("打开日志文件时出错: \(error)") } } private func writeToFile(_ message: String) { guard let data = message.data(using: .utf8) else { return } do { if fileHandle == nil { // 如果fileHandle为nil,尝试重新打开文件 openLogFile() } if #available(iOS 13.4, macOS 10.15.4, watchOS 6.4, tvOS 13.4, *) { try fileHandle?.write(contentsOf: data) } else { fileHandle?.write(data) } } catch { print("写入日志文件时出错: \(error)") // 如果无法写入,关闭并尝试重新打开 try? fileHandle?.close() fileHandle = nil } } private func checkSizeRotation() { do { let attributes = try fileManager.attributesOfItem(atPath: fileURL.path) let fileSize = attributes[.size] as? UInt64 ?? 0 if fileSize >= rotationConfig.maxFileSize { performRotation() } } catch { print("检查文件大小时出错: \(error)") } } private func performRotation() { do { // 关闭当前文件句柄 try fileHandle?.close() fileHandle = nil // 获取日志目录 let directory = fileURL.deletingLastPathComponent() let filename = fileURL.lastPathComponent // 获取当前备份日志列表 let baseFilename = filename let pattern = "^\(baseFilename)\\.(\\d+)$" let regex = try NSRegularExpression(pattern: pattern, options: []) let logFiles = try fileManager.contentsOfDirectory(atPath: directory.path) .filter { filename in let range = NSRange(location: 0, length: filename.utf16.count) return regex.firstMatch(in: filename, options: [], range: range) != nil } .sorted { file1, file2 in // 提取备份号并比较 let range1 = NSRange(location: 0, length: file1.utf16.count) let range2 = NSRange(location: 0, length: file2.utf16.count) guard let match1 = regex.firstMatch(in: file1, options: [], range: range1), let match2 = regex.firstMatch(in: file2, options: [], range: range2) else { return false } let numberRange1 = match1.range(at: 1) let numberRange2 = match2.range(at: 1) let number1 = strToInt(file1.utf16.dropFirst(baseFilename.utf16.count + 1).prefix(numberRange1.length)) ?? 0 let number2 = strToInt(file2.utf16.dropFirst(baseFilename.utf16.count + 1).prefix(numberRange2.length)) ?? 0 return number1 < number2 } // 如果我们有太多备份日志,则删除旧的备份日志 if logFiles.count >= rotationConfig.maxBackupCount { for i in (rotationConfig.maxBackupCount - 1).. Int? { guard let numStr = String(number) else { return nil } return Int(numStr) } }