Created
February 16, 2020 14:00
-
-
Save pofat/7e547410690d6039129304fc2d2728d3 to your computer and use it in GitHub Desktop.
Scan unused ObjC selectors
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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") | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Thanks for sharing! Two minor issues:
Typo here:
"Build by
swfitc scan_unused_selectors.swift
"swfitc -> swiftc
For the latest Apple Swift version 5.1.3 to compile:
let shouldFilterPrefix = []
should give an explicit type:
let shouldFilterPrefix : [String] = []