Skip to content

Instantly share code, notes, and snippets.

@pofat
Created February 16, 2020 14:00
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save pofat/7e547410690d6039129304fc2d2728d3 to your computer and use it in GitHub Desktop.
Save pofat/7e547410690d6039129304fc2d2728d3 to your computer and use it in GitHub Desktop.
Scan unused ObjC selectors
/*
* Note: You need to build this script before use
* Usage:
* 1. Build by `swfitc scan_unused_selectors.swift`
* 2. Run by `./scan_unused_selectors /path/to/yourMachO`
*
* How to locate MachO of your APP (`/path/to/yourMachO` in above example)? In your Xcod project navigator, you can find a folder `Products`
* In that folder you can see your app (after any build) and right click on it -> "Show In Finder"
* You can get your APP's location by drag it into your terminal. Say it's "/path/to/MyApp.app", your MachO path would be "/path/to/MyApp.app/MyApp"
*/
import Foundation
// MARK: Global Variables
// Put the prefix of class names which you'd like to skip, mostly from Pods
let shouldFilterPrefix = []
let shouldFilterContained = [".cxx_construct", ".cxx_destruct"]
// MARK: Class - Stdout Reader
/// A sequence to read out stdout line by line because we want to access stdout via for-in loop
class StdoutReader {
let encoding: String.Encoding = .utf8
let chunkSize = 4096
var atEof = false
var fileHandle: FileHandle
let delimData: Data
var buffer: Data
init(fileHandle: FileHandle, delimiter: String = "\n") {
self.fileHandle = fileHandle
self.delimData = delimiter.data(using: encoding)!
self.buffer = Data(capacity: chunkSize)
self.atEof = false
}
/// Return next line, or nil when EOF.
func nextLine() -> String? {
// Read data chunks from file until a line delimiter is found:
while !atEof {
if let range = buffer.range(of: delimData) {
let line = String(data: buffer.subdata(in: 0..<range.lowerBound), encoding: encoding)
// Clear buffer
buffer.removeSubrange(0..<range.upperBound)
return line
}
let tempData = fileHandle.readData(ofLength: chunkSize)
if !tempData.isEmpty {
buffer.append(tempData)
} else {
// EOF or read error.
atEof = true
if !buffer.isEmpty {
let line = String(data: buffer as Data, encoding: encoding)
buffer.count = 0
return line
}
}
}
return nil
}
}
extension StdoutReader: Sequence {
func makeIterator() -> AnyIterator<String> {
return AnyIterator {
return self.nextLine()
}
}
}
// MARK: MachO Parser
/// Check if given path is a readable MachO file and copy it to temp folder
///
/// - Parameter args: Absolute path of MachO executable
/// - Returns: Path of copied MachO file (in tmp folder)
func verifyMachO(args: [String]) -> String? {
if args.count != 2 {
print("Usgae: swift objc_unref.swift $MACHO_PATH")
return nil
}
let path = args[1]
if FileManager.default.isReadableFile(atPath: path) {
let newFileName = (path as NSString).lastPathComponent.replacingOccurrences(of: " ", with: "_")
let newPath = NSTemporaryDirectory() + newFileName
do {
try FileManager.default.copyItem(atPath: path, toPath: newPath)
// use `file` to check executable file type
let pipe = execute(command: "/usr/bin/file", arguments: ["-b", newPath])
let data = pipe.fileHandleForReading.readDataToEndOfFile()
if let stdout = String(data: data, encoding: .utf8), stdout.hasPrefix("Mach-O") {
return newPath
} else {
print("\(path) is not a Mach-O executable")
return nil
}
} catch CocoaError.fileWriteFileExists {
// If file exists, remove it
do {
try FileManager.default.removeItem(atPath: newPath)
} catch {
print("Try to remove existed copy file failed: \(error)")
return nil
}
// And copy it again
do {
try FileManager.default.copyItem(atPath: path, toPath: newPath)
return newPath
} catch {
print("Copy operation failed again. Abort with error: \(error)")
return nil
}
} catch {
print("Failed to copy file from \(path) to \(newPath) due to \(error)")
return nil
}
} else {
print("It's not a valid readable file: \(path)")
return nil
}
}
/// Based on prefix and contained filter condition, check if we should filter this signal
///
/// - Parameter signal: Signal string, e.g. "-[UIViewController viewDidLoad]"
/// - Returns: A Bool to indicate if we should ignore this signal
func shouldFilter(signal: String) -> Bool {
var className = signal.components(separatedBy: " ")[0]
let beginIndex = className.index(className.startIndex, offsetBy: 2)
className = String(className[beginIndex...])
for pre in shouldFilterPrefix {
if className.hasPrefix(pre) {
return true
}
}
for chars in shouldFilterContained {
if signal.contains(chars) {
return true
}
}
return false
}
/// A handy function to exeute shell command
///
/// - Parameters:
/// - command: command to run
/// - arguments: all arguments
/// - Returns: A pipe of stdout
func execute(command: String, arguments: [String]) -> Pipe {
let process = Process()
process.launchPath = command
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.launch()
return pipe
}
/// Get list of all implemented selectors in MachO
///
/// - Parameter path: MachO path
/// - Returns: Return dictionary is in format of : [selector1: [sig1, sig2], selector2: [sig3, sig4]]
func listImplmentedSelectors(atPath path: String) -> [String: [String]] {
let regex = "\\s*imp 0x\\w+ ([+|-]\\[.+\\s(.+)\\])"
let selectorRegex = "([+|-]\\[.+\\s(.+)\\])"
var result: [String: [String]] = [:]
let pipe = execute(command: "/usr/bin/otool", arguments: ["-oV", path])
let reader = StdoutReader(fileHandle: pipe.fileHandleForReading)
for line in reader {
guard line.range(of: regex, options: .regularExpression) != nil,
let range = line.range(of: selectorRegex, options: .regularExpression) else {
continue
}
let signal = String(line[range])
if shouldFilter(signal: signal) {
continue
}
var selector = signal.components(separatedBy: " ")[1]
let index = selector.index(selector.endIndex, offsetBy: -1)
selector = String(selector[..<index])
if var signals = result[selector] {
signals.append(signal)
result[selector] = signals
} else {
result[selector] = [signal]
}
}
return result
}
/// Get referenced selectors
/// Get list of all referenced selectors
///
/// - Parameter path: MachO path
/// - Returns: A set of all referenced selectors
func listReferencedSelectors(atPath path: String) -> Set<String> {
let fullRegex = "__TEXT:__objc_methname:(.+)"
let prefixRegex = "__TEXT:__objc_methname:"
var result: Set<String> = []
let arguments = ["-v", "-s", "__DATA", "__objc_selrefs", path]
let pipe = execute(command: "/usr/bin/otool", arguments: arguments)
let reader = StdoutReader(fileHandle: pipe.fileHandleForReading)
for line in reader {
guard line.range(of: fullRegex, options: .regularExpression) != nil,
let range = line.range(of: prefixRegex, options: .regularExpression) else {
continue
}
let selector = String(line[range.upperBound..<line.endIndex])
result.insert(selector)
}
return result
}
/// List potentially unreferenced signals
///
/// - Parameter path: MachO path
/// - Returns: All signals might be unreferenced (sorted and filtered)
func listPontentiallyUnreferencedSelectors(atPath path: String) -> [String] {
var result: [String] = []
let implemented = listImplmentedSelectors(atPath: path)
guard !implemented.isEmpty else {
print("Can not find implementation of selectors")
exit(0)
}
let referenced = listReferencedSelectors(atPath: path)
for selector in implemented.keys {
if !referenced.contains(selector) {
result += implemented[selector]!
}
}
return result.sorted { (lhs, rhs) -> Bool in
let lindex = lhs.index(lhs.startIndex, offsetBy: 2)
let rindex = rhs.index(rhs.startIndex, offsetBy: 2)
let left = String(lhs[lindex..<lhs.endIndex])
let right = String(rhs[rindex..<rhs.endIndex])
return left < right
}
}
// MARK: Main
let arguments = CommandLine.arguments
if let path = verifyMachO(args: arguments) {
print("Following are potentially unreferenced selectors")
let results = listPontentiallyUnreferencedSelectors(atPath: path)
var classes: Set<String> = []
for result in results {
let spaceIndex = result.firstIndex(of: " ")!
let beginIndex = result.index(result.startIndex, offsetBy: 2)
classes.insert(String(result[beginIndex..<spaceIndex]))
// You can export to a file if you want.
print(result)
}
print("\n\(classes.count) classes and \(results.count) selectors")
} else {
print("failed to open MachO file")
}
@denkeni
Copy link

denkeni commented Feb 19, 2020

Thanks for sharing! Two minor issues:

  1. Typo here:
    "Build by swfitc scan_unused_selectors.swift"
    swfitc -> swiftc

  2. For the latest Apple Swift version 5.1.3 to compile:
    let shouldFilterPrefix = []
    should give an explicit type:
    let shouldFilterPrefix : [String] = []

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment