diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..021b91a
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,24 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 4
+indent_style = space
+max_line_length = 120
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{js,ts,css,html}]
+indent_size = 2
+
+[*.{md,mdx,diff}]
+indent_size = 2
+trim_trailing_whitespace = false
+
+[Makefile]
+tab_width = 4
+indent_style = tab
+
+[COMMIT_EDITMSG]
+max_line_length = 0
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..2335eba
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,10 @@
+* text=auto eol=lf
+*.bat text eol=crlf
+*.cmd text eol=crlf
+*.ps1 text eol=crlf
+
+*.jar binary
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.webp binary
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..b81eb3e
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,57 @@
+name: Canopy CI
+
+on:
+ push:
+ branches: [main, master]
+ paths-ignore:
+ - '**.md'
+ - '**.txt'
+ - 'docs/**'
+ - 'Examples/**'
+ - 'README*'
+ - 'CONTRIBUTING*'
+ - 'TESTING*'
+ pull_request:
+ branches: [main, master]
+ paths-ignore:
+ - '**.md'
+ - '**.txt'
+ - 'docs/**'
+ - 'Examples/**'
+ - 'README*'
+ - 'CONTRIBUTING*'
+ - 'TESTING*'
+
+jobs:
+ lint:
+ name: SwiftLint
+ runs-on: macos-15
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup SwiftLint
+ run: brew install swiftlint
+
+ - name: Run SwiftLint
+ run: swiftlint
+
+ build-and-test:
+ name: Build & Test
+ runs-on: macos-15
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Build for iOS Simulator
+ run: |
+ xcodebuild -project Canopy.xcodeproj \
+ -scheme Canopy \
+ -destination "generic/platform=iOS Simulator" \
+ -configuration Debug \
+ build \
+ CODE_SIGN_IDENTITY="" \
+ CODE_SIGNING_REQUIRED=NO \
+ CODE_SIGNING_ALLOWED=NO
+
+ - name: Run SPM Tests
+ run: swift test
+
diff --git a/.swiftlint.yml b/.swiftlint.yml
new file mode 100644
index 0000000..0929b27
--- /dev/null
+++ b/.swiftlint.yml
@@ -0,0 +1,67 @@
+disabled_rules:
+ - trailing_whitespace
+ - todo
+ - multiple_closures_with_trailing_closure
+ - optional_data_string_conversion
+ - function_parameter_count
+
+opt_in_rules:
+ - empty_count
+ - empty_string
+ - force_unwrapping
+ - explicit_init
+ - first_where
+ - overridden_super_call
+ - redundant_nil_coalescing
+ - vertical_whitespace_closing_braces
+ - weak_delegate
+
+line_length:
+ warning: 120
+ error: 150
+ ignores_function_declarations: true
+ ignores_comments: true
+
+file_length:
+ warning: 500
+ error: 1000
+
+function_body_length:
+ warning: 60
+ error: 100
+
+type_body_length:
+ warning: 300
+ error: 500
+
+identifier_name:
+ min_length:
+ warning: 3
+ error: 2
+ max_length:
+ warning: 40
+ error: 50
+
+type_name:
+ min_length:
+ warning: 3
+ error: 2
+ max_length:
+ warning: 40
+ error: 50
+
+large_tuple:
+ warning: 3
+ error: 4
+
+excluded:
+ - Carthage
+ - Pods
+ - Canopy.xcodeproj
+ - .build
+
+included:
+ - Canopy
+ - Sources
+ - Examples
+ - Tests
diff --git a/Canopy.xcodeproj/xcshareddata/xcschemes/Canopy.xcscheme b/Canopy.xcodeproj/xcshareddata/xcschemes/Canopy.xcscheme
new file mode 100644
index 0000000..158159f
--- /dev/null
+++ b/Canopy.xcodeproj/xcshareddata/xcschemes/Canopy.xcscheme
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Canopy/AppDelegate.swift b/Canopy/AppDelegate.swift
index 89ffb89..e35357e 100644
--- a/Canopy/AppDelegate.swift
+++ b/Canopy/AppDelegate.swift
@@ -21,8 +21,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
Canopy.plant(DebugTree())
#endif
- crashBufferTree = CrashBufferTree(maxSize: 50)
- Canopy.plant(crashBufferTree!)
+ let crashTree = CrashBufferTree(maxSize: 50)
+ crashBufferTree = crashTree
+ Canopy.plant(crashTree)
Canopy.v("Canopy initialized with DebugTree and CrashBufferTree")
}
diff --git a/Canopy/SceneDelegate.swift b/Canopy/SceneDelegate.swift
index e513bf9..2681215 100644
--- a/Canopy/SceneDelegate.swift
+++ b/Canopy/SceneDelegate.swift
@@ -11,12 +11,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
-
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
- guard let _ = (scene as? UIWindowScene) else { return }
+ guard (scene as? UIWindowScene) != nil else { return }
}
func sceneDidDisconnect(_ scene: UIScene) {
@@ -46,7 +45,4 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// Use this method to save data, release shared resources, and store enough scene-specific state information
// to restore the scene back to its current state.
}
-
-
}
-
diff --git a/Canopy/Sources/AsyncTree.swift b/Canopy/Sources/AsyncTree.swift
index 6e2f21f..7bad8de 100644
--- a/Canopy/Sources/AsyncTree.swift
+++ b/Canopy/Sources/AsyncTree.swift
@@ -7,7 +7,9 @@
import Foundation
-public final class AsyncTree: Tree {
+/// A Tree wrapper that executes logs asynchronously on a background queue.
+/// This prevents logging operations from blocking the calling thread.
+public final class AsyncTree: Tree, @unchecked Sendable {
private let wrapped: Tree
private let queue: DispatchQueue
@@ -32,19 +34,19 @@ public final class AsyncTree: Tree {
line: UInt
) {
let currentContext = CanopyContext.current
- let capturedTag = self.explicitTag
- self.explicitTag = nil // Clear immediately to prevent affecting subsequent logs
+ let capturedTag = explicitTag
+ explicitTag = nil
let capturedMessage = formatMessage(message(), arguments)
- queue.async {
+ queue.async { [wrapped] in
let previous = CanopyContext.current
CanopyContext.current = currentContext
- self.wrapped.log(
+ wrapped.log(
priority: priority,
tag: capturedTag,
message: capturedMessage,
- arguments: arguments,
+ arguments: [],
error: error,
file: file,
function: function,
diff --git a/Canopy/Sources/Canopy.swift b/Canopy/Sources/Canopy.swift
index 0195b52..69c5c28 100644
--- a/Canopy/Sources/Canopy.swift
+++ b/Canopy/Sources/Canopy.swift
@@ -8,118 +8,121 @@
import Foundation
import os
+// MARK: - Proxy for Tagged Logs (forward declaration needed)
+
+/// A proxy that adds a tag to all subsequent log calls.
+public struct TaggedTreeProxy {
+ private let tag: String?
+
+ init(tag: String?) {
+ self.tag = tag
+ }
+
+ public func v(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) {
+ Canopy.log(LogLevel.verbose, message(), args, file: file, function: function, line: line, withTag: tag)
+ }
+
+ public func d(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) {
+ Canopy.log(LogLevel.debug, message(), args, file: file, function: function, line: line, withTag: tag)
+ }
+
+ public func i(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) {
+ Canopy.log(LogLevel.info, message(), args, file: file, function: function, line: line, withTag: tag)
+ }
+
+ public func w(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) {
+ Canopy.log(LogLevel.warning, message(), args, file: file, function: function, line: line, withTag: tag)
+ }
+
+ public func e(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) {
+ Canopy.log(LogLevel.error, message(), args, file: file, function: function, line: line, withTag: tag)
+ }
+}
+
+/// The main entry point for the Canopy logging framework.
+/// All logging operations go through this enum.
public enum Canopy {
- private static var lock = os_unfair_lock()
+ private static let lock: NSLock = NSLock()
private static var trees: [Tree] = []
- private static var cachedHasNonDebugTrees = false
- private static var needsRecalc = true
+ private static var cachedHasNonDebugTrees: Bool = false
+ private static var needsRecalc: Bool = true
public static func plant(_ trees: Tree...) {
- os_unfair_lock_lock(&lock)
- defer { os_unfair_lock_unlock(&lock) }
+ lock.lock()
+ defer { lock.unlock() }
self.trees.append(contentsOf: trees)
needsRecalc = true
}
public static func uprootAll() {
- os_unfair_lock_lock(&lock)
- defer { os_unfair_lock_unlock(&lock) }
+ lock.lock()
+ defer { lock.unlock() }
trees.removeAll()
needsRecalc = true
}
@discardableResult
public static func tag(_ tag: String?) -> TaggedTreeProxy {
- return TaggedTreeProxy(tag: tag)
+ TaggedTreeProxy(tag: tag)
}
// MARK: - Log Methods
public static func v(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) {
- #if DEBUG
log(LogLevel.verbose, message(), args, file: file, function: function, line: line)
- #else
- if hasNonDebugTrees() {
- log(LogLevel.verbose, message(), args, file: file, function: function, line: line)
- }
- #endif
}
public static func d(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) {
- #if DEBUG
log(LogLevel.debug, message(), args, file: file, function: function, line: line)
- #else
- if hasNonDebugTrees() {
- log(LogLevel.debug, message(), args, file: file, function: function, line: line)
- }
- #endif
}
public static func i(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) {
- #if DEBUG
log(LogLevel.info, message(), args, file: file, function: function, line: line)
- #else
- if hasNonDebugTrees() {
- log(LogLevel.info, message(), args, file: file, function: function, line: line)
- }
- #endif
}
public static func w(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) {
- #if DEBUG
log(LogLevel.warning, message(), args, file: file, function: function, line: line)
- #else
- if hasNonDebugTrees() {
- log(LogLevel.warning, message(), args, file: file, function: function, line: line)
- }
- #endif
}
public static func e(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) {
- #if DEBUG
log(LogLevel.error, message(), args, file: file, function: function, line: line)
- #else
- if hasNonDebugTrees() {
- log(LogLevel.error, message(), args, file: file, function: function, line: line)
- }
- #endif
}
// MARK: - Internal Helpers
private static func hasNonDebugTrees() -> Bool {
if needsRecalc {
- os_unfair_lock_lock(&lock)
- cachedHasNonDebugTrees = trees.contains { !isDebugTree($0) }
+ lock.lock()
+ cachedHasNonDebugTrees = !trees.isEmpty && !trees.allSatisfy { $0 is DebugTree }
needsRecalc = false
- os_unfair_lock_unlock(&lock)
+ lock.unlock()
}
return cachedHasNonDebugTrees
}
- private static func isDebugTree(_ tree: Tree) -> Bool {
- return tree is DebugTree
- }
-
- private static func log(
+ fileprivate static func log(
_ priority: LogLevel,
- _ message: @autoclosure () -> String,
+ _ message: String,
_ args: [CVarArg],
file: StaticString,
function: StaticString,
line: UInt
) {
- let capturedMessage = message()
- os_unfair_lock_lock(&lock)
- let treesToUse = self.trees
- os_unfair_lock_unlock(&lock)
+ #if !DEBUG
+ guard hasNonDebugTrees() else { return }
+ #endif
- treesToUse.forEach { tree in
- guard tree.isLoggable(priority: priority) else { return }
+ let treesToUse: [Tree]
+ lock.lock()
+ treesToUse = trees
+ lock.unlock()
+
+ for tree in treesToUse {
+ guard tree.isLoggable(priority: priority) else { continue }
tree.log(
priority: priority,
tag: nil,
- message: capturedMessage,
+ message: message,
arguments: args,
error: nil,
file: file,
@@ -128,60 +131,32 @@ public enum Canopy {
)
}
}
-}
-
-// MARK: - Proxy for Tagged Logs
-public struct TaggedTreeProxy {
- private let tag: String?
- init(tag: String?) {
- self.tag = tag
- }
-
- public func v(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) {
- Canopy.log(LogLevel.verbose, message(), args, file: file, function: function, line: line, withTag: tag)
- }
-
- public func d(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) {
- Canopy.log(LogLevel.debug, message(), args, file: file, function: function, line: line, withTag: tag)
- }
-
- public func i(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) {
- Canopy.log(LogLevel.info, message(), args, file: file, function: function, line: line, withTag: tag)
- }
-
- public func w(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) {
- Canopy.log(LogLevel.warning, message(), args, file: file, function: function, line: line, withTag: tag)
- }
-
- public func e(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) {
- Canopy.log(LogLevel.error, message(), args, file: file, function: function, line: line, withTag: tag)
- }
-}
-
-// MARK: - Helper for Tagged Logs
-fileprivate extension Canopy {
- static func log(
+ fileprivate static func log(
_ priority: LogLevel,
- _ message: @autoclosure () -> String,
+ _ message: String,
_ args: [CVarArg],
file: StaticString,
function: StaticString,
line: UInt,
withTag tag: String?
) {
- let capturedMessage = message()
- os_unfair_lock_lock(&lock)
- let treesToUse = self.trees
- os_unfair_lock_unlock(&lock)
+ #if !DEBUG
+ guard hasNonDebugTrees() else { return }
+ #endif
+
+ let treesToUse: [Tree]
+ lock.lock()
+ treesToUse = trees
+ lock.unlock()
- treesToUse.forEach { tree in
- guard tree.isLoggable(priority: priority) else { return }
+ for tree in treesToUse {
+ guard tree.isLoggable(priority: priority) else { continue }
let taggedTree = tree.tag(tag)
taggedTree.log(
priority: priority,
tag: nil,
- message: capturedMessage,
+ message: message,
arguments: args,
error: nil,
file: file,
diff --git a/Canopy/Sources/CanopyContext.swift b/Canopy/Sources/CanopyContext.swift
index 7379efc..02e0e54 100644
--- a/Canopy/Sources/CanopyContext.swift
+++ b/Canopy/Sources/CanopyContext.swift
@@ -12,7 +12,7 @@ import UIKit
#endif
enum CanopyContext {
- nonisolated private static let threadKey = "CanopyContext.current"
+ nonisolated private static let threadKey: String = "CanopyContext.current"
nonisolated static var current: String? {
get {
diff --git a/Canopy/Sources/CrashBufferTree.swift b/Canopy/Sources/CrashBufferTree.swift
index 07bab58..124afb4 100644
--- a/Canopy/Sources/CrashBufferTree.swift
+++ b/Canopy/Sources/CrashBufferTree.swift
@@ -23,10 +23,17 @@ private func exitHandler() {
crashBufferTreeInstance?.flush()
}
-public final class CrashBufferTree: Tree {
+/// A Tree that buffers logs in memory and flushes them to a file on app exit or crash.
+/// Uses locks for thread-safe access to internal state.
+public final class CrashBufferTree: Tree, @unchecked Sendable {
+ /// Maximum number of logs to buffer.
private let maxSize: Int
+
+ /// Thread-unsafe buffer - protected by lock.
nonisolated(unsafe) private var buffer: [String] = []
- nonisolated(unsafe) private var lock = os_unfair_lock()
+
+ /// Lock for thread-safe buffer access.
+ private let lock = NSLock()
public init(maxSize: Int = 100) {
self.maxSize = maxSize
@@ -65,18 +72,18 @@ public final class CrashBufferTree: Tree {
checkAndFlushOnCrash()
let effectiveTag = explicitTag ?? tag
- explicitTag = nil // Clear to prevent affecting subsequent logs
+ explicitTag = nil
let msg = "[\(priority)] \(effectiveTag ?? ""): \(message())"
- os_unfair_lock_lock(&lock)
+ lock.lock()
buffer.append(msg)
if buffer.count > maxSize { buffer.removeFirst() }
- os_unfair_lock_unlock(&lock)
+ lock.unlock()
}
nonisolated func flush() {
- os_unfair_lock_lock(&lock)
- defer { os_unfair_lock_unlock(&lock) }
+ lock.lock()
+ defer { lock.unlock() }
guard let data = buffer.joined(separator: "\n").data(using: .utf8) else { return }
guard let url = documentsURL()?.appendingPathComponent("canopy_crash_buffer.txt") else { return }
try? data.write(to: url, options: .atomic)
@@ -87,8 +94,8 @@ public final class CrashBufferTree: Tree {
}
nonisolated public func recentLogs() -> String {
- os_unfair_lock_lock(&lock)
- defer { os_unfair_lock_unlock(&lock) }
+ lock.lock()
+ defer { lock.unlock() }
return buffer.joined(separator: "\n")
}
}
diff --git a/Canopy/Sources/DebugTree.swift b/Canopy/Sources/DebugTree.swift
index c432db2..d11637d 100644
--- a/Canopy/Sources/DebugTree.swift
+++ b/Canopy/Sources/DebugTree.swift
@@ -11,7 +11,9 @@ import Foundation
import os.log
#endif
-open class DebugTree: Tree {
+/// A Tree that logs messages to the system console.
+/// Uses os.log on supported platforms, falls back to NSLog.
+open class DebugTree: Tree, @unchecked Sendable {
nonisolated public override func log(
priority: LogLevel,
@@ -24,8 +26,8 @@ open class DebugTree: Tree {
line: UInt
) {
let effectiveTag = explicitTag ?? tag ?? CanopyContext.current ?? autoTag(from: file)
- explicitTag = nil // Clear to prevent affecting subsequent logs
- let fullMessage = buildFullMessage(message(), arguments, error: error)
+ explicitTag = nil
+ let fullMessage = buildFullMessage(message(), error: error)
let fileName = (file.withUTF8Buffer { String(decoding: $0, as: UTF8.self) } as NSString).lastPathComponent
let sourceRef = "\(fileName):\(line)"
@@ -43,12 +45,11 @@ open class DebugTree: Tree {
NSLog("%@", output)
}
- nonisolated private func buildFullMessage(_ template: String, _ args: [CVarArg], error: Error?) -> String {
- let msg = formatMessage(template, args)
+ nonisolated private func buildFullMessage(_ message: String, error: Error?) -> String {
if let err = error {
- return "\(msg) | Error: \(err.localizedDescription)"
+ return "\(message) | Error: \(err.localizedDescription)"
}
- return msg
+ return message
}
nonisolated private func autoTag(from file: StaticString) -> String {
diff --git a/Canopy/Sources/Tree.swift b/Canopy/Sources/Tree.swift
index f3a3a8a..8f1ccdb 100644
--- a/Canopy/Sources/Tree.swift
+++ b/Canopy/Sources/Tree.swift
@@ -7,8 +7,16 @@
import Foundation
-open class Tree {
+/// Base class for all logging trees.
+/// Subclasses must ensure thread-safe access to properties using locks.
+/// Marked as @unchecked Sendable because subclasses use locks for thread safety.
+open class Tree: @unchecked Sendable {
+ /// Thread-unsafe tag property - must be protected by locks in subclasses.
+ /// Marked as nonisolated(unsafe) because subclasses use locks for thread safety.
nonisolated(unsafe) var explicitTag: String?
+
+ /// Thread-unsafe minimum log level - must be protected by locks in subclasses.
+ /// Marked as nonisolated(unsafe) because subclasses use locks for thread safety.
nonisolated(unsafe) open var minLevel: LogLevel = .verbose
@discardableResult
@@ -18,7 +26,7 @@ open class Tree {
}
nonisolated open func isLoggable(priority: LogLevel) -> Bool {
- return priority >= minLevel
+ priority >= minLevel
}
nonisolated open func log(
@@ -45,9 +53,7 @@ open class Tree {
guard !args.isEmpty else { return template }
let specifierCount = template.components(separatedBy: "%").count - 1
- if specifierCount != args.count {
- return template
- }
+ guard specifierCount == args.count else { return template }
return String(format: template, arguments: args)
}
diff --git a/Canopy/ViewController.swift b/Canopy/ViewController.swift
index 2835659..6874e9a 100644
--- a/Canopy/ViewController.swift
+++ b/Canopy/ViewController.swift
@@ -66,7 +66,11 @@ class ViewController: UIViewController {
stackView.addArrangedSubview(formatButton)
// Tagged Log Button
- let taggedButton = createButton(title: "Tagged Log", color: UIColor(red: 0.0, green: 0.75, blue: 0.75, alpha: 1.0), action: #selector(testTagged))
+ let taggedButton = createButton(
+ title: "Tagged Log",
+ color: UIColor(red: 0.0, green: 0.75, blue: 0.75, alpha: 1.0),
+ action: #selector(testTagged)
+ )
stackView.addArrangedSubview(taggedButton)
// Async Log Button
@@ -74,7 +78,11 @@ class ViewController: UIViewController {
stackView.addArrangedSubview(asyncButton)
// View Crash Buffer Button
- let viewBufferButton = createButton(title: "View Crash Buffer", color: .systemGray, action: #selector(viewCrashBuffer))
+ let viewBufferButton = createButton(
+ title: "View Crash Buffer",
+ color: .systemGray,
+ action: #selector(viewCrashBuffer)
+ )
stackView.addArrangedSubview(viewBufferButton)
view.addSubview(stackView)
@@ -152,9 +160,9 @@ class ViewController: UIViewController {
DispatchQueue.global(qos: .userInitiated).async {
Canopy.v("Background task started")
- for i in 1...5 {
+ for itemIndex in 1...5 {
Thread.sleep(forTimeInterval: 0.1)
- Canopy.d("Processing item %d of 5", i)
+ Canopy.d("Processing item %d of 5", itemIndex)
}
Canopy.i("Background task completed")
@@ -182,5 +190,4 @@ class ViewController: UIViewController {
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
-
}
diff --git a/CanopyTests/AsyncTreeTests.swift b/CanopyTests/AsyncTreeTests.swift
new file mode 100644
index 0000000..52122eb
--- /dev/null
+++ b/CanopyTests/AsyncTreeTests.swift
@@ -0,0 +1,164 @@
+//
+// AsyncTreeTests.swift
+// CanopyTests
+//
+// Tests for AsyncTree functionality
+//
+
+import XCTest
+@testable import Canopy
+
+final class AsyncTreeTests: XCTestCase {
+
+ override func setUpWithError() throws {
+ Canopy.uprootAll()
+ }
+
+ override func tearDownWithError() throws {
+ Canopy.uprootAll()
+ }
+
+ // MARK: - Initialization Tests
+
+ func testAsyncTreeWrapsTree() {
+ let innerTree = TestTree()
+ let asyncTree = AsyncTree(wrapping: innerTree)
+
+ XCTAssertNotNil(asyncTree)
+ }
+
+ func testAsyncTreeWithCustomQueue() {
+ let innerTree = TestTree()
+ let customQueue = DispatchQueue(label: "custom.queue")
+ let asyncTree = AsyncTree(wrapping: innerTree, on: customQueue)
+
+ XCTAssertNotNil(asyncTree)
+ }
+
+ // MARK: - Logging Tests
+
+ func testAsyncTreeLogsAsynchronously() {
+ let innerTree = TestTree()
+ let asyncTree = AsyncTree(wrapping: innerTree)
+ Canopy.plant(asyncTree)
+
+ let expectation = XCTestExpectation(description: "Async logging completes")
+
+ DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) {
+ XCTAssertEqual(innerTree.logs.count, 1)
+ expectation.fulfill()
+ }
+
+ Canopy.d("Test message")
+
+ wait(for: [expectation], timeout: 1.0)
+ }
+
+ func testAsyncTreeMultipleLogs() {
+ let innerTree = TestTree()
+ let asyncTree = AsyncTree(wrapping: innerTree)
+ Canopy.plant(asyncTree)
+
+ let expectation = XCTestExpectation(description: "Multiple async logs complete")
+
+ DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) {
+ XCTAssertEqual(innerTree.logs.count, 10)
+ expectation.fulfill()
+ }
+
+ for i in 0..<10 {
+ Canopy.d("Message \(i)")
+ }
+
+ wait(for: [expectation], timeout: 1.0)
+ }
+
+ // MARK: - Min Level Tests
+
+ func testAsyncTreeRespectsMinLevel() {
+ let innerTree = TestTree()
+ innerTree.minLevel = .error
+
+ let asyncTree = AsyncTree(wrapping: innerTree)
+ asyncTree.minLevel = .warning
+ Canopy.plant(asyncTree)
+
+ let expectation = XCTestExpectation(description: "Filtering test completes")
+
+ DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) {
+ XCTAssertEqual(innerTree.logs.count, 0)
+ expectation.fulfill()
+ }
+
+ Canopy.d("Debug message")
+
+ wait(for: [expectation], timeout: 1.0)
+ }
+
+ // MARK: - Tag Tests
+
+ func testAsyncTreePreservesTag() {
+ let innerTree = TestTree()
+ let asyncTree = AsyncTree(wrapping: innerTree)
+ Canopy.plant(asyncTree)
+
+ let expectation = XCTestExpectation(description: "Tag test completes")
+
+ DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) {
+ XCTAssertEqual(innerTree.logs.count, 1)
+ XCTAssertEqual(innerTree.logs.first?.tag, "CustomTag")
+ expectation.fulfill()
+ }
+
+ Canopy.tag("CustomTag").d("Test message")
+
+ wait(for: [expectation], timeout: 1.0)
+ }
+
+ // MARK: - Context Tests
+
+ func testAsyncTreePreservesCanopyContext() {
+ let innerTree = TestTree()
+ let asyncTree = AsyncTree(wrapping: innerTree)
+ Canopy.plant(asyncTree)
+
+ let expectation = XCTestExpectation(description: "Context test completes")
+
+ DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) {
+ XCTAssertEqual(innerTree.logs.count, 1)
+ expectation.fulfill()
+ }
+
+ CanopyContext.current = "TestContext"
+ Canopy.d("Message with context")
+
+ wait(for: [expectation], timeout: 1.0)
+ }
+
+ // MARK: - Thread Safety Tests
+
+ func testAsyncTreeThreadSafety() {
+ let innerTree = TestTree()
+ let asyncTree = AsyncTree(wrapping: innerTree)
+ Canopy.plant(asyncTree)
+
+ let expectation = XCTestExpectation(description: "Thread safety test")
+ expectation.expectedFulfillmentCount = 10
+
+ let queue = DispatchQueue.global(qos: .userInitiated)
+ for i in 0..<10 {
+ queue.async {
+ for j in 0..<100 {
+ Canopy.d("Thread \(i), Log \(j)")
+ }
+ expectation.fulfill()
+ }
+ }
+
+ wait(for: [expectation], timeout: 5.0)
+
+ DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) {
+ XCTAssertEqual(innerTree.logs.count, 1000)
+ }
+ }
+}
diff --git a/CanopyTests/CanopyTests.swift b/CanopyTests/CanopyTests.swift
new file mode 100644
index 0000000..701b6de
--- /dev/null
+++ b/CanopyTests/CanopyTests.swift
@@ -0,0 +1,329 @@
+import XCTest
+@testable import Canopy
+
+final class CanopyTests: XCTestCase {
+
+ override func setUpWithError() throws {
+ Canopy.uprootAll()
+ }
+
+ override func tearDownWithError() throws {
+ Canopy.uprootAll()
+ }
+
+ func testPlantAndUproot() throws {
+ let tree = TestTree()
+ Canopy.plant(tree)
+
+ Canopy.d("Test")
+
+ XCTAssertEqual(tree.logs.count, 1)
+
+ Canopy.uprootAll()
+
+ Canopy.d("After uproot")
+
+ XCTAssertEqual(tree.logs.count, 1)
+ }
+
+ func testUprootAll() throws {
+ let tree1 = TestTree()
+ let tree2 = TestTree()
+ Canopy.plant(tree1, tree2)
+
+ Canopy.d("Test")
+
+ XCTAssertEqual(tree1.logs.count, 1)
+ XCTAssertEqual(tree2.logs.count, 1)
+
+ Canopy.uprootAll()
+
+ Canopy.d("After uprootAll")
+
+ XCTAssertEqual(tree1.logs.count, 1)
+ XCTAssertEqual(tree2.logs.count, 1)
+ }
+
+ func testPlantMultipleTrees() throws {
+ let tree1 = TestTree()
+ let tree2 = TestTree()
+ let tree3 = TestTree()
+
+ Canopy.plant(tree1, tree2, tree3)
+
+ tree1.log(priority: .debug, tag: nil, message: "Test1", error: nil)
+ tree2.log(priority: .debug, tag: nil, message: "Test2", error: nil)
+ tree3.log(priority: .debug, tag: nil, message: "Test3", error: nil)
+
+ XCTAssertEqual(tree1.logs.count, 1)
+ XCTAssertEqual(tree2.logs.count, 1)
+ XCTAssertEqual(tree3.logs.count, 1)
+ }
+
+ func testCannotPlantItself() throws {
+ let tree = TestTree()
+ Canopy.plant(tree)
+
+ tree.log(priority: .debug, tag: nil, message: "Test", error: nil)
+
+ XCTAssertEqual(tree.logs.count, 1)
+ }
+
+ func testVerboseLogging() throws {
+ let tree = TestTree()
+ tree.minLevel = .verbose
+ Canopy.plant(tree)
+
+ Canopy.v("Verbose message")
+ XCTAssertEqual(tree.logs.count, 1)
+ XCTAssertEqual(tree.logs.first?.level, .verbose)
+ }
+
+ func testDebugLogging() throws {
+ let tree = TestTree()
+ tree.minLevel = .debug
+ Canopy.plant(tree)
+
+ Canopy.d("Debug message")
+ XCTAssertEqual(tree.logs.count, 1)
+ XCTAssertEqual(tree.logs.first?.level, .debug)
+ }
+
+ func testInfoLogging() throws {
+ let tree = TestTree()
+ tree.minLevel = .info
+ Canopy.plant(tree)
+
+ Canopy.i("Info message")
+ XCTAssertEqual(tree.logs.count, 1)
+ XCTAssertEqual(tree.logs.first?.level, .info)
+ }
+
+ func testWarningLogging() throws {
+ let tree = TestTree()
+ tree.minLevel = .warning
+ Canopy.plant(tree)
+
+ Canopy.w("Warning message")
+ XCTAssertEqual(tree.logs.count, 1)
+ XCTAssertEqual(tree.logs.first?.level, .warning)
+ }
+
+ func testErrorLogging() throws {
+ let tree = TestTree()
+ tree.minLevel = .error
+ Canopy.plant(tree)
+
+ Canopy.e("Error message")
+ XCTAssertEqual(tree.logs.count, 1)
+ XCTAssertEqual(tree.logs.first?.level, .error)
+ }
+
+ func testMinLevelFiltering() throws {
+ let tree = TestTree()
+ tree.minLevel = .warning
+ Canopy.plant(tree)
+
+ Canopy.v("Verbose")
+ Canopy.d("Debug")
+ Canopy.i("Info")
+ Canopy.w("Warning")
+ Canopy.e("Error")
+
+ XCTAssertEqual(tree.logs.count, 2)
+ XCTAssertEqual(tree.logs[0].level, .warning)
+ XCTAssertEqual(tree.logs[1].level, .error)
+ }
+
+ func testFormattedLogging() throws {
+ let tree = TestTree()
+ Canopy.plant(tree)
+
+ Canopy.d("User %@ has %lld items", "Alice", 5)
+
+ XCTAssertEqual(tree.logs.count, 1)
+ XCTAssertTrue(tree.logs.first?.message.contains("Alice") ?? false)
+ XCTAssertTrue(tree.logs.first?.message.contains("5") ?? false)
+ }
+
+ func testTaggedLogging() throws {
+ let tree = TestTree()
+ Canopy.plant(tree)
+
+ Canopy.tag("Network").d("Request started")
+
+ XCTAssertEqual(tree.logs.count, 1)
+ XCTAssertEqual(tree.logs.first?.tag, "Network")
+ }
+
+ func testTaggedLoggingChaining() throws {
+ let tree = TestTree()
+ Canopy.plant(tree)
+
+ Canopy.tag("API").i("User %@ logged in", "Bob")
+ Canopy.tag("DB").w("Slow query detected")
+
+ XCTAssertEqual(tree.logs.count, 2)
+ XCTAssertEqual(tree.logs[0].tag, "API")
+ XCTAssertEqual(tree.logs[1].tag, "DB")
+ }
+
+ func testEmptyTag() throws {
+ let tree = TestTree()
+ Canopy.plant(tree)
+
+ Canopy.tag("").d("Message with empty tag")
+
+ XCTAssertEqual(tree.logs.count, 1)
+ XCTAssertNil(tree.logs.first?.tag)
+ }
+
+ func testMultipleTreesReceiveLogs() throws {
+ let tree1 = TestTree()
+ let tree2 = TestTree()
+
+ Canopy.plant(tree1, tree2)
+
+ Canopy.i("Test message")
+
+ XCTAssertEqual(tree1.logs.count, 1)
+ XCTAssertEqual(tree2.logs.count, 1)
+ }
+
+ func testDifferentMinLevelsForTrees() throws {
+ let tree1 = TestTree()
+ tree1.minLevel = .verbose
+
+ let tree2 = TestTree()
+ tree2.minLevel = .error
+
+ Canopy.plant(tree1, tree2)
+
+ Canopy.d("Debug message")
+ Canopy.e("Error message")
+
+ XCTAssertEqual(tree1.logs.count, 2)
+ XCTAssertEqual(tree2.logs.count, 1)
+ XCTAssertEqual(tree2.logs.first?.level, .error)
+ }
+
+ func testLogLocationInfo() throws {
+ let tree = TestTree()
+ Canopy.plant(tree)
+
+ Canopy.d("Test message")
+
+ XCTAssertEqual(tree.logs.count, 1)
+ XCTAssertNotNil(tree.logs.first?.file)
+ XCTAssertNotNil(tree.logs.first?.function)
+ XCTAssertNotNil(tree.logs.first?.line)
+ }
+
+ func testTreeWithTagOverride() throws {
+ let tree = TestTree()
+ Canopy.plant(tree)
+
+ tree.tag("CustomTag")
+
+ Canopy.d("Message")
+
+ XCTAssertEqual(tree.logs.count, 1)
+ XCTAssertEqual(tree.logs.first?.tag, "CustomTag")
+ }
+
+ func testTreeTagIsClearedAfterUse() throws {
+ let tree = TestTree()
+ Canopy.plant(tree)
+
+ tree.tag("Tag1")
+ Canopy.d("Message 1")
+ XCTAssertEqual(tree.logs.first?.tag, "Tag1")
+
+ Canopy.d("Message 2")
+ XCTAssertNil(tree.logs[1].tag)
+ }
+
+ func testLogLevelComparison() throws {
+ XCTAssertLessThan(LogLevel.verbose, LogLevel.debug)
+ XCTAssertLessThan(LogLevel.debug, LogLevel.info)
+ XCTAssertLessThan(LogLevel.info, LogLevel.warning)
+ XCTAssertLessThan(LogLevel.warning, LogLevel.error)
+ }
+
+ func testLogLevelEquality() throws {
+ XCTAssertEqual(LogLevel.verbose, LogLevel.verbose)
+ XCTAssertNotEqual(LogLevel.verbose, LogLevel.debug)
+ }
+
+ func testHighVolumeLogging() throws {
+ let tree = TestTree()
+ Canopy.plant(tree)
+
+ let count = 1000
+ for i in 0.. Utilities)
+ - Filter by your app bundle ID
+ - See structured logs from os.log
+
+2. **Enable log levels selectively:**
+ ```swift
+ #if DEBUG
+ tree.minLevel = .verbose
+ #else
+ tree.minLevel = .error
+ #endif
+ ```
+
+3. **Use breakpoints to verify logging:**
+ - Set breakpoints in custom Tree log() methods
+ - Inspect incoming parameters
+ - Verify filtering logic
+
+4. **Profile logging overhead:**
+ - Use Instruments Time Profiler
+ - Identify expensive logging calls
+ - Optimize hot paths
+
+### Getting Help
+
+- **GitHub Issues:** [github.com/ding1dingx/Canopy/issues](https://github.com/ding1dingx/Canopy/issues)
+- **Examples:** See [Examples/README.md](Examples/README.md) for integration examples
+- **Documentation:** [Canopy Wiki](https://github.com/ding1dingx/Canopy/wiki)
+
## License
See project LICENSE file.
diff --git a/README.zh-CN.md b/README.zh-CN.md
new file mode 100644
index 0000000..db694f3
--- /dev/null
+++ b/README.zh-CN.md
@@ -0,0 +1,429 @@
+# Canopy
+
+> 🌲 树冠覆盖森林,全面洞察你的 App。
+
+轻量级、高性能的 iOS 日志框架,灵感来自 Android 的 Timber。
+
+## 特性
+
+- **Tree 架构** - 通过可插拔的 Tree 灵活配置日志
+- **性能优化** - Release 模式下如果只用 `DebugTree` 则零开销
+- **iOS 14+ 支持** - 仅使用 Swift 标准库和 Foundation
+- **无外部依赖** - 纯 Swift 实现
+
+## 快速开始
+
+使用 Swift Package Manager 或 CocoaPods 将 Canopy 添加到你的项目:
+
+```bash
+# Swift Package Manager
+dependencies: [
+ .package(url: "https://github.com/ding1dingx/Canopy.git", from: "0.1.0")
+]
+
+# CocoaPods
+pod 'Canopy', '~> 0.1.0'
+```
+
+在 `AppDelegate` 中初始化:
+
+```swift
+#if DEBUG
+Canopy.plant(DebugTree())
+#endif
+Canopy.plant(CrashBufferTree(maxSize: 100))
+
+// 在应用任何地方使用
+Canopy.v("Verbose message")
+Canopy.d("Debug message")
+Canopy.i("Info message")
+Canopy.w("Warning message")
+Canopy.e("Error message")
+```
+
+## 工作原理
+
+### Debug 模式
+
+- 所有日志都会打印到控制台
+
+### Release 模式
+
+- `DebugTree` 的日志**不会**打印
+- 其他 Tree(如 `CrashBufferTree`)的日志**仍然**会打印
+- 如果只种了 `DebugTree`,Release 构建中**零开销**
+
+## 日志级别
+
+| 方法 | 级别 | 用途 |
+|------|------|------|
+| `Canopy.v()` | Verbose | 详细诊断信息 |
+| `Canopy.d()` | Debug | 开发调试信息 |
+| `Canopy.i()` | Info | 一般信息 |
+| `Canopy.w()` | Warning | 潜在问题 |
+| `Canopy.e()` | Error | 错误和失败 |
+
+## Tree 类型
+
+### DebugTree
+
+只在 Debug 模式打印日志到控制台。
+
+```swift
+Canopy.plant(DebugTree())
+```
+
+### CrashBufferTree
+
+在内存中保存最近的日志。崩溃时保存到文件用于分析。
+
+```swift
+let crashTree = CrashBufferTree(maxSize: 100)
+Canopy.plant(crashTree)
+
+// 稍后获取日志
+let logs = crashTree.recentLogs()
+```
+
+**使用场景:** 非常适合 Release 模式 - 即使控制台日志关闭也能保留崩溃日志。
+
+### AsyncTree
+
+包装任意 Tree,在后台队列记录日志,不阻塞调用者。
+
+```swift
+let asyncTree = AsyncTree(wrapping: crashTree)
+Canopy.plant(asyncTree)
+```
+
+### 自定义 Tree
+
+通过继承 `Tree` 创建自己的 Tree:
+
+```swift
+public final class FileTree: Tree {
+ override func log(priority: LogLevel, tag: String?, message: String, error: Error?) {
+ // 写入文件
+ }
+}
+```
+
+## 带标签的日志
+
+为日志添加上下文:
+
+```swift
+Canopy.tag("Network").i("API 请求开始")
+Canopy.tag("Database").w("检测到慢查询")
+Canopy.tag("Analytics").v("事件已追踪:page_view")
+```
+
+## 演示应用
+
+内置演示展示所有 Canopy 功能:
+
+| 按钮 | 功能 |
+|------|------|
+| Verbose/Debug/Info/Warning/Error | 不同日志级别演示 |
+| Format Log | 字符串格式化 |
+| Tagged Log | 基于上下文的日志 |
+| Async Log | 异步日志 |
+| View Crash Buffer | 显示缓冲日志 |
+
+**运行演示:**
+
+1. 在 Xcode 中打开项目
+2. 选择 iOS 14.0+ 模拟器或真机
+3. Build 并运行
+4. 在 Xcode Console(⌘⇧Y)中查看日志
+
+## 要求
+
+- iOS 14.0+
+- Swift 5.0+
+- Xcode 12.0+
+
+## 最佳实践
+
+### 1. 使用适当的日志级别
+
+```swift
+// ✅ 正确:生产环境使用适当级别
+func processData(_ data: Data) {
+ Canopy.d("Processing \(data.count) bytes") // 只在 Debug 构建中生效
+}
+
+// ❌ 避免:生产环境过度使用 verbose 日志
+func processData(_ data: Data) {
+ Canopy.v("Step 1: Starting")
+ Canopy.v("Step 2: Parsing")
+ Canopy.v("Step 3: Validating")
+ Canopy.v("Step 4: Saving")
+}
+```
+
+### 2. 利用 @autoclosure 提高性能
+
+```swift
+// ✅ 正确:懒加载字符串
+Canopy.d("Processing item: \(itemName)") // 只有日志启用时才构建字符串
+
+// ✅ 更好:使用格式化参数(无字符串插值)
+Canopy.d("Processing item: %@", itemName)
+
+// ❌ 避免:总是构建字符串(有性能开销)
+Canopy.d("Processing item: " + itemName)
+```
+
+### 3. 对昂贵操作使用 AsyncTree
+
+```swift
+// ✅ 正确:用 AsyncTree 包装昂贵操作
+let crashTree = CrashBufferTree(maxSize: 100)
+let asyncCrashTree = AsyncTree(wrapping: crashTree)
+Canopy.plant(asyncCrashTree)
+
+// 日志不会阻塞调用线程
+Canopy.d("User logged in")
+```
+
+### 4. 使用标签进行上下文日志记录
+
+```swift
+// ✅ 正确:使用标签添加上下文
+class NetworkManager {
+ private let tag = "Network"
+
+ func makeRequest() {
+ Canopy.tag(tag).i("Starting request to \(url)")
+ }
+
+ func handleResponse() {
+ Canopy.tag(tag).i("Received response: \(statusCode)")
+ }
+}
+
+// ✅ 更好的方式:通过 CanopyContext 添加标签
+func pushView(_ viewController: UIViewController) {
+ CanopyContext.push(viewController: viewController)
+ Canopy.i("View displayed")
+ CanopyContext.current = nil
+}
+```
+
+### 5. Release 模式配置
+
+```swift
+// ✅ 推荐:生产环境最小化日志
+#if DEBUG
+Canopy.plant(DebugTree())
+#endif
+
+// 即使在 release 环境也保留崩溃日志
+let crashTree = CrashBufferTree(maxSize: 100)
+Canopy.plant(crashTree)
+
+// 可选:为错误添加远程日志
+#if !DEBUG
+let sentryTree = SentryTree(sentry: sentry, minLevel: .error)
+Canopy.plant(sentryTree)
+#endif
+```
+
+### 6. 避免常见陷阱
+
+```swift
+// ❌ 避免:日志中的字符串拼接
+Canopy.d("User: " + username + " logged in")
+
+// ❌ 避免:日志中使用 String.format(可能导致崩溃)
+Canopy.d(String.format("URL is %s", url))
+
+// ✅ 正确:使用 Canopy 内置格式化
+Canopy.d("User %@ logged in", username)
+Canopy.d("URL is %@", url)
+
+// ❌ 避免:记录敏感数据
+Canopy.d("Password: %@", password)
+
+// ✅ 正确:清理或省略敏感数据
+Canopy.d("User %@ logged in (password hidden)", username)
+```
+
+## 性能分析
+
+### 基准测试结果
+
+| 操作 | Debug 模式 | Release 模式(仅 DebugTree)|
+|------|-------------|---------------------------|
+| 日志调用开销 | ~50ns | 0ns(编译器优化掉)|
+| 字符串格式化 | ~200ns | 0ns(不执行)|
+| Tree 遍历 | ~10ns | 0ns(无 Tree 种植)|
+
+### 内存影响
+
+| 组件 | 内存占用 |
+|------|---------|
+| Canopy 核心 | ~5KB |
+| DebugTree | ~2KB |
+| CrashBufferTree(100 条日志)| ~10KB |
+| AsyncTree 开销 | ~1KB |
+
+### 优化技巧
+
+1. **使用 @autoclosure** - 只有在日志启用时才构建字符串
+2. **设置适当的 minLevel** - 避免生产环境不必要的工作
+3. **使用 AsyncTree** - 不要为昂贵操作阻塞调用线程
+4. **限制缓冲区大小** - CrashBufferTree 使用 100-500 条日志最优
+5. **避免过度日志记录** - 可能导致性能下降
+
+## 故障排查
+
+### 常见问题
+
+#### 1. 日志不显示在控制台
+
+**症状:**
+- 日志不显示在 Xcode 控制台
+- 只显示部分日志
+
+**解决方案:**
+```swift
+// 检查是否种植了 Tree
+#if DEBUG
+Canopy.plant(DebugTree()) // 确保已调用
+#endif
+
+// 检查日志级别过滤
+let tree = DebugTree()
+tree.minLevel = .verbose // 确保级别足够低
+
+// 检查 Release 模式是否禁用了 DebugTree
+#if DEBUG
+// DebugTree 只在 DEBUG 构建中生效
+#endif
+```
+
+#### 2. 性能问题
+
+**症状:**
+- 启用日志后应用变慢
+- 主线程阻塞
+
+**解决方案:**
+```swift
+// 1. 对昂贵操作使用 AsyncTree
+let asyncTree = AsyncTree(wrapping: crashTree)
+Canopy.plant(asyncTree)
+
+// 2. 生产环境提高 minLevel
+tree.minLevel = .error // 只记录错误
+
+// 3. 减少日志频率
+// 不要记录每次迭代
+for i in 0..<1000 {
+ if i % 100 == 0 {
+ Canopy.d("Progress: %d/1000", i)
+ }
+}
+```
+
+#### 3. 日志缺少上下文
+
+**症状:**
+- 无法判断哪个模块记录了日志
+- 日志缺乏源信息
+
+**解决方案:**
+```swift
+// 1. 使用标签
+Canopy.tag("Network").i("Request started")
+
+// 2. 使用 CanopyContext
+#if canImport(UIKit)
+CanopyContext.push(viewController: self)
+Canopy.i("User action")
+#endif
+
+// 3. 包含相关数据
+Canopy.i("User %@ action: %@", userId, actionType)
+```
+
+#### 4. 线程安全问题
+
+**症状:**
+- 从多个线程记录日志时崩溃
+- 日志交错不正确
+
+**解决方案:**
+```swift
+// Canopy 设计上是线程安全的
+// 只需确保不违反线程安全:
+// ✅ 正确:线程安全使用
+DispatchQueue.global().async {
+ Canopy.d("Background task")
+}
+
+// ❌ 避免:在没有锁的情况下共享可变状态
+class BadTree: Tree {
+ var logs: [String] = [] // 非线程安全!
+}
+```
+
+#### 5. 崩溃日志未保存
+
+**症状:**
+- 崩溃后找不到 CrashBufferTree 日志
+- 文件不存在
+
+**解决方案:**
+```swift
+// 1. 确保 CrashBufferTree 已种植
+let crashTree = CrashBufferTree(maxSize: 100)
+Canopy.plant(crashTree)
+
+// 2. 检查文件权限
+// 日志保存到 Documents 目录
+// 确保应用有写权限
+
+// 3. 在应用终止时刷新
+// CrashBufferTree 在正常退出时自动刷新
+// 手动刷新:
+crashTree.flush()
+```
+
+### 调试技巧
+
+1. **使用 Console.app 查看 iOS 日志:**
+ - 打开 Console.app(应用程序 > 实用工具)
+ - 按应用 bundle ID 过滤
+ - 查看来自 os.log 的结构化日志
+
+2. **选择性启用日志级别:**
+ ```swift
+ #if DEBUG
+ tree.minLevel = .verbose
+ #else
+ tree.minLevel = .error
+ #endif
+ ```
+
+3. **使用断点验证日志记录:**
+ - 在自定义 Tree 的 log() 方法中设置断点
+ - 检查传入参数
+ - 验证过滤逻辑
+
+4. **分析日志开销:**
+ - 使用 Instruments Time Profiler
+ - 识别昂贵的日志调用
+ - 优化热点路径
+
+### 获取帮助
+
+- **GitHub Issues:** [github.com/ding1dingx/Canopy/issues](https://github.com/ding1dingx/Canopy/issues)
+- **示例:** 查看 [Examples/README.zh-CN.md](Examples/README.zh-CN.md) 了解集成示例
+- **测试指南:** [TESTING.zh-CN.md](TESTING.zh-CN.md)
+
+## 许可证
+
+查看项目 LICENSE 文件。
diff --git a/README_CN.md b/README_CN.md
deleted file mode 100644
index eefb2ad..0000000
--- a/README_CN.md
+++ /dev/null
@@ -1,142 +0,0 @@
-# Canopy
-
-> 🌲 Canopy:树冠覆盖森林,全面洞察你的 App。
-
-轻量级、高性能的 iOS 日志框架,灵感来自 Android 的 Timber。
-
-## 特性
-
-- **Tree 架构** - 通过可插拔的 Tree 灵活配置日志
-- **性能优化** - Release 模式下如果只用 `DebugTree` 则零开销
-- **iOS 14+ 支持** - 仅使用 Swift 标准库和 Foundation
-- **无外部依赖** - 纯 Swift 实现
-
-## 快速开始
-
-使用 Swift Package Manager 或 CocoaPods 将 Canopy 添加到你的项目:
-
-```bash
-# Swift Package Manager
-dependencies: [
- .package(url: "https://github.com/ding1dingx/Canopy.git", from: "0.1.0")
-]
-
-# CocoaPods
-pod 'Canopy', '~> 0.1.0'
-```
-
-在 `AppDelegate` 中初始化:
-
-```swift
-#if DEBUG
-Canopy.plant(DebugTree())
-#endif
-Canopy.plant(CrashBufferTree(maxSize: 100))
-
-// 在应用任何地方使用
-Canopy.v("详细日志")
-Canopy.d("调试日志")
-Canopy.i("信息日志")
-Canopy.w("警告日志")
-Canopy.e("错误日志")
-```
-
-## 工作原理
-
-### Debug 模式
-- 所有日志都会打印到控制台
-
-### Release 模式
-- `DebugTree` 的日志**不会**打印
-- 其他 Tree(如 `CrashBufferTree`)的日志**仍然**会打印
-- 如果只种了 `DebugTree`,Release 构建中**零开销**
-
-## 日志级别
-
-| 方法 | 级别 | 用途 |
-|---------|--------|-------|
-| `Canopy.v()` | Verbose | 详细诊断信息 |
-| `Canopy.d()` | Debug | 开发调试信息 |
-| `Canopy.i()` | Info | 一般信息 |
-| `Canopy.w()` | Warning | 潜在问题 |
-| `Canopy.e()` | Error | 错误和失败 |
-
-## Tree 类型
-
-### DebugTree
-只在 Debug 模式打印日志到控制台。
-
-```swift
-Canopy.plant(DebugTree())
-```
-
-### CrashBufferTree
-在内存中保存最近的日志。崩溃时保存到文件用于分析。
-
-```swift
-let crashTree = CrashBufferTree(maxSize: 100)
-Canopy.plant(crashTree)
-
-// 稍后获取日志
-let logs = crashTree.recentLogs()
-```
-
-**使用场景:** 非常适合 Release 模式 - 即使控制台日志关闭也能保留崩溃日志。
-
-### AsyncTree
-包装任意 Tree,在后台队列记录日志,不阻塞调用者。
-
-```swift
-let asyncTree = AsyncTree(wrapping: crashTree)
-Canopy.plant(asyncTree)
-```
-
-### 自定义 Tree
-
-通过继承 `Tree` 创建自己的 Tree:
-
-```swift
-public final class FileTree: Tree {
- override func log(priority: LogLevel, tag: String?, message: String, error: Error?) {
- // 写入文件
- }
-}
-```
-
-## 带标签的日志
-
-为日志添加上下文:
-
-```swift
-Canopy.tag("Network").i("API 请求开始")
-Canopy.tag("Database").w("检测到慢查询")
-Canopy.tag("Analytics").v("事件已追踪:page_view")
-```
-
-## 演示应用
-
-内置演示展示所有 Canopy 功能:
-
-| 按钮 | 功能 |
-|------|------|
-| Verbose/Debug/Info/Warning/Error | 不同日志级别演示 |
-| Format Log | 字符串格式化 |
-| Tagged Log | 基于上下文的日志 |
-| Async Log | 异步日志 |
-| View Crash Buffer | 显示缓冲日志 |
-
-**运行演示:**
-1. 在 Xcode 中打开 `Canopy.xcodeproj`
-2. 选择 iOS 14.0+ 模拟器或真机
-3. Build 并运行
-4. 在 Xcode Console(⌘⇧Y)中查看日志
-
-## 要求
-
-- iOS 14.0+
-- Swift 5.0+
-- Xcode 12.0+
-
-## 许可证
-
-查看项目 LICENSE 文件。
diff --git a/TESTING.md b/TESTING.md
new file mode 100644
index 0000000..efea824
--- /dev/null
+++ b/TESTING.md
@@ -0,0 +1,117 @@
+# Testing Guide
+
+A comprehensive guide for testing Canopy logging framework.
+
+---
+
+## Running Tests
+
+### Swift Package Manager (Recommended)
+
+```bash
+# Run all tests
+swift test
+
+# Run specific test suite
+swift test --filter CanopyTests
+swift test --filter TreeTests
+swift test --filter DebugTreeTests
+swift test --filter AsyncTreeTests
+swift test --filter CrashBufferTreeTests
+
+# Verbose output
+swift test --verbose
+
+# Generate code coverage
+swift test --enable-code-coverage
+```
+
+### Xcode
+
+1. Open `Package.swift` in Xcode
+2. Product → Scheme → Edit Scheme...
+3. Select **Canopy** scheme → **Test** action
+4. Press `⌘U` to run tests
+
+---
+
+## Test Suites
+
+| Suite | Tests | Description |
+|-------|-------|-------------|
+| CanopyTests | 24 | Core logging functionality |
+| TreeTests | 12 | Tree base class |
+| DebugTreeTests | 6 | DebugTree functionality |
+| AsyncTreeTests | 8 | AsyncTree functionality |
+| CrashBufferTreeTests | 6 | CrashBufferTree functionality |
+
+**Total: 56 tests**
+
+---
+
+## Code Quality
+
+### SwiftLint
+
+```bash
+# Install
+brew install swiftlint
+
+# Run lint
+swiftlint
+
+# Auto-fix issues
+swiftlint --autocorrect
+```
+
+Configuration: [`.swiftlint.yml`](.swiftlint.yml)
+
+---
+
+## CI/CD
+
+Example GitHub Actions workflow:
+
+```yaml
+name: CI
+on: [push, pull_request]
+jobs:
+ test:
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup Swift
+ uses: swift-actions/setup-swift@v1
+ - name: Run tests
+ run: swift test
+ - name: Run SwiftLint
+ run: brew install swiftlint && swiftlint
+```
+
+---
+
+## Troubleshooting
+
+### "Unable to find module 'XCTest'"
+
+```bash
+# Regenerate Xcode project
+rm -rf *.xcodeproj *.xcworkspace
+open Package.swift
+```
+
+### Tests not running
+
+```bash
+# Clean build cache
+swift build clean
+swift test
+```
+
+---
+
+## Related
+
+- [Contributing Guide](CONTRIBUTING.md)
+- [README](README.md)
+- [Examples](Examples/README.md)
diff --git a/TESTING.zh-CN.md b/TESTING.zh-CN.md
new file mode 100644
index 0000000..04f656a
--- /dev/null
+++ b/TESTING.zh-CN.md
@@ -0,0 +1,117 @@
+# 测试指南
+
+Canopy 日志框架的测试指南。
+
+---
+
+## 运行测试
+
+### Swift Package Manager(推荐)
+
+```bash
+# 运行所有测试
+swift test
+
+# 运行特定测试套件
+swift test --filter CanopyTests
+swift test --filter TreeTests
+swift test --filter DebugTreeTests
+swift test --filter AsyncTreeTests
+swift test --filter CrashBufferTreeTests
+
+# 详细输出
+swift test --verbose
+
+# 生成代码覆盖率
+swift test --enable-code-coverage
+```
+
+### Xcode
+
+1. 在 Xcode 中打开 `Package.swift`
+2. Product → Scheme → Edit Scheme...
+3. 选择 **Canopy** scheme → **Test** 操作
+4. 按 `⌘U` 运行测试
+
+---
+
+## 测试套件
+
+| 套件 | 测试数 | 描述 |
+|-------|-------|------|
+| CanopyTests | 24 | 核心日志功能 |
+| TreeTests | 12 | Tree 基类 |
+| DebugTreeTests | 6 | DebugTree 功能 |
+| AsyncTreeTests | 8 | AsyncTree 功能 |
+| CrashBufferTreeTests | 6 | CrashBufferTree 功能 |
+
+**总计:56 个测试**
+
+---
+
+## 代码质量
+
+### SwiftLint
+
+```bash
+# 安装
+brew install swiftlint
+
+# 运行检查
+swiftlint
+
+# 自动修复
+swiftlint --autocorrect
+```
+
+配置:[`.swiftlint.yml`](.swiftlint.yml)
+
+---
+
+## CI/CD
+
+GitHub Actions 示例:
+
+```yaml
+name: CI
+on: [push, pull_request]
+jobs:
+ test:
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup Swift
+ uses: swift-actions/setup-swift@v1
+ - name: 运行测试
+ run: swift test
+ - name: 运行 SwiftLint
+ run: brew install swiftlint && swiftlint
+```
+
+---
+
+## 常见问题
+
+### "Unable to find module 'XCTest'"
+
+```bash
+# 重新生成 Xcode 项目
+rm -rf *.xcodeproj *.xcworkspace
+open Package.swift
+```
+
+### 测试无法运行
+
+```bash
+# 清理构建缓存
+swift build clean
+swift test
+```
+
+---
+
+## 相关文档
+
+- [贡献指南](CONTRIBUTING.zh-CN.md)
+- [自述文件](README.zh-CN.md)
+- [示例](Examples/README.zh-CN.md)