diff --git a/Package.swift b/Package.swift index 8f1491292..852295600 100644 --- a/Package.swift +++ b/Package.swift @@ -134,7 +134,7 @@ let package = Package( dependencies: [ "SwiftJava", "SwiftJavaRuntimeSupport", - "SwiftRuntimeFunctions" + "SwiftRuntimeFunctions", ] ), @@ -400,7 +400,7 @@ let package = Package( .testTarget( name: "SwiftRuntimeFunctionsTests", dependencies: [ - "SwiftRuntimeFunctions", + "SwiftRuntimeFunctions" ], swiftSettings: [ .swiftLanguageMode(.v5) diff --git a/Plugins/JExtractSwiftPlugin/JExtractSwiftPlugin.swift b/Plugins/JExtractSwiftPlugin/JExtractSwiftPlugin.swift index 1ca1f5052..9c2f48a5c 100644 --- a/Plugins/JExtractSwiftPlugin/JExtractSwiftPlugin.swift +++ b/Plugins/JExtractSwiftPlugin/JExtractSwiftPlugin.swift @@ -117,6 +117,17 @@ struct JExtractSwiftBuildToolPlugin: SwiftJavaPluginProtocol, BuildToolPlugin { let jextractOutputFiles = outputSwiftFiles + // In JNI mode, emit a linker version script so the linker can DCE unused Swift code. + // Placed in the plugin work directory root + // NOTE: intentionally NOT added to jextractOutputFiles — SPM would otherwise treat + // the .map file as a resource and force-link Foundation as a side effect. + if configuration?.effectiveMode == .jni { + let linkerExportListFile = context.pluginWorkDirectoryURL.appending(path: "swift-java-jni-exports.map") + arguments += [ + "--linker-export-list-output", linkerExportListFile.path(percentEncoded: false), + ] + } + // If the developer has enabled java callbacks in the configuration (default is false) // and we are running in JNI mode, we will run additional phases in this build plugin // to generate Swift wrappers using wrap-java that can be used to callback to Java. diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift index e8531787e..7ecddb9f1 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift @@ -119,6 +119,49 @@ extension JNISwift2JavaGenerator { } } + /// Writes a linker version script to the path specified by + /// ``Configuration/linkerExportListOutput``, listing every JNI ``@_cdecl`` + /// symbol generated during this run as global exports and hiding everything + /// else with `local: *`. + /// + /// Pass the resulting file to the linker with: + /// ``` + /// -Xlinker --version-script= + /// ``` + /// This lets lld treat only the JNI entry points as roots during link-time + /// dead-code elimination and hides all internal Swift symbols from the + /// dynamic symbol table, removing unreachable Swift code from SPM + /// dependencies and the Swift standard library. + func writeLinkerExportList() throws { + guard let outputPath = config.linkerExportListOutput else { + return + } + guard !generatedCDeclSymbolNames.isEmpty else { + return + } + + let symbolLines = + generatedCDeclSymbolNames + .sorted() + .map { " \($0);" } + .joined(separator: "\n") + let contents = + """ + { + global: + \(symbolLines) + local: *; + };" + """ + + try contents.write( + toFile: outputPath, + atomically: true, + encoding: .utf8 + ) + logger.info("[swift-java] Generated linker export list (\(generatedCDeclSymbolNames.count) symbols): \(outputPath)") + } + private func printJNICache(_ printer: inout CodePrinter, _ type: ImportedNominalType) { printer.printBraceBlock("enum \(JNICaching.cacheName(for: type))") { printer in for enumCase in type.cases { @@ -722,6 +765,8 @@ extension JNISwift2JavaGenerator { + "__" + jniSignature.escapedJNIIdentifier + self.generatedCDeclSymbolNames.append(cName) + let translatedParameters = parameters.map { "\($0.name): \($0.type.jniTypeName)" } @@ -736,6 +781,9 @@ extension JNISwift2JavaGenerator { // TODO: Think about function overloads printer.printBraceBlock( """ + #if compiler(>=6.3) + @used + #endif @_cdecl("\(cName)") public func \(cName)(\(thunkParameters.joined(separator: ", ")))\(thunkReturnType) """ diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift index a6917227a..27ca90e0e 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift @@ -39,6 +39,11 @@ package class JNISwift2JavaGenerator: Swift2JavaGenerator { var thunkNameRegistry = ThunkNameRegistry() + /// Accumulates every ``@_cdecl`` symbol name emitted during thunk printing. + /// Written to a linker version script after generation when + /// ``Configuration/linkerExportListOutput`` is set. + var generatedCDeclSymbolNames: [String] = [] + /// Cached Java translation result. 'nil' indicates failed translation. var translatedDecls: [ImportedFunc: TranslatedFunctionDecl] = [:] var translatedEnumCases: [ImportedEnumCase: TranslatedEnumCase] = [:] @@ -103,6 +108,7 @@ package class JNISwift2JavaGenerator: Swift2JavaGenerator { func generate() throws { try writeSwiftThunkSources() try writeExportedJavaSources() + try writeLinkerExportList() let pendingFileCount = self.expectedOutputSwiftFileNames.count if pendingFileCount > 0 { diff --git a/Sources/SwiftJava/JNI.swift b/Sources/SwiftJava/JNI.swift index 4614906e4..c7eeaad3d 100644 --- a/Sources/SwiftJava/JNI.swift +++ b/Sources/SwiftJava/JNI.swift @@ -51,6 +51,9 @@ package final class JNI { } } +#if compiler(>=6.3) +@used +#endif @_cdecl("JNI_OnLoad") public func SwiftJava_JNI_OnLoad(javaVM: JavaVMPointer, reserved: UnsafeMutableRawPointer) -> jint { JNI.shared = JNI(fromVM: JavaVirtualMachine(adoptingJVM: javaVM)) diff --git a/Sources/SwiftJavaConfigurationShared/Configuration.swift b/Sources/SwiftJavaConfigurationShared/Configuration.swift index ae2e53cff..e3ab0aea8 100644 --- a/Sources/SwiftJavaConfigurationShared/Configuration.swift +++ b/Sources/SwiftJavaConfigurationShared/Configuration.swift @@ -67,6 +67,14 @@ public struct Configuration: Codable { public var generatedJavaSourcesListFileOutput: String? + /// If set, JExtract (JNI mode) will write a linker version script to this + /// path, listing all generated JNI ``@_cdecl`` entry-point symbols as + /// global exports and hiding everything else with `local: *`. Pass this + /// file to the linker via ``-Xlinker --version-script=`` to enable + /// precise dead-code elimination of unused Swift code in the final shared + /// library. + public var linkerExportListOutput: String? + // ==== wrap-java --------------------------------------------------------- /// The Java class path that should be passed along to the swift-java tool. diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/Android.md b/Sources/SwiftJavaDocumentation/Documentation.docc/Android.md index 68902e87c..67d16de86 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/Android.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/Android.md @@ -41,4 +41,80 @@ open class OldVersionedClass: JavaObject { Annotations are generated both for "since", "deprecated" and "removed" attributes. -> Note: To use Android platform availability you must use at least Swift 6.3, which introduced the `Android` platform. \ No newline at end of file +> Note: To use Android platform availability you must use at least Swift 6.3, which introduced the `Android` platform. + +## Reducing Binary Size + +When using the `jextract` tool to wrap your Swift APIs as a Java library targeting Android, several compiler and linker options can substantially reduce the final binary size by stripping dead code that would otherwise be retained. + +### Requirements + +Full binary-size optimization requires **Swift 6.3 or later**. Swift 6.3 introduced the `@used` attribute, which `JExtractSwiftPlugin` attaches to every generated JNI entry point so the compiler cannot eliminate them before the linker has a chance to see them. + +### Generated Version Script + +When using the `jextract` tool in JNI mode, `JExtractSwiftPlugin` automatically generates a linker version script alongside the Swift thunks. The version script lists every JNI entry point as a `global:` export and hides everything else with `local: *`, giving the linker precise control over which symbols must be kept and allowing it to discard all internal Swift symbols. + +The file is written to the plugin's work directory: + +``` +.build/plugins/outputs///JExtractSwiftPlugin/swift-java-jni-exports.map +``` + +### Optimization Flags + +The following flags, used together, produce the smallest possible binary: + +| Flag | Effect | +|---|---| +| `-Xswiftc -Osize` | Optimize for binary size rather than speed | +| `-Xlinker --version-script=` | Restrict exported symbols to JNI entry points; hides internal Swift symbols from the dynamic symbol table | +| `--experimental-lto-mode=full` | Full link-time optimization across all modules | +| `-Xfrontend -internalize-at-link` | Internalize Swift symbols at link time, enabling the linker to eliminate more dead code | + +### Package.swift + +Add the flags that don't depend on a dynamic path directly to your `Package.swift`, conditioned on release builds for Android: + +```swift +import PackageDescription + +let package = Package( + name: "MyLibrary", + products: [ + .library(name: "MySwiftLibrary", type: .dynamic, targets: ["MySwiftLibrary"]) + ], + dependencies: [ + .package(url: "https://github.com/swiftlang/swift-java", from: "0.1.0"), + ], + targets: [ + .target( + name: "MySwiftLibrary", + dependencies: [ + .product(name: "SwiftJava", package: "swift-java") + ], + swiftSettings: [ + .unsafeFlags( + ["-Osize", "-Xfrontend", "-internalize-at-link"], + .when(platforms: [.android], configuration: .release) + ), + ], + plugins: [ + .plugin(name: "JExtractSwiftPlugin", package: "swift-java") + ] + ) + ] +) +``` + +Then pass the remaining flags on the command line when invoking the build: + +```bash +swift build \ + --swift-sdk aarch64-unknown-linux-android28 \ + -c release \ + --experimental-lto-mode=full \ + -Xlinker --version-script=.build/plugins/outputs/MyLibrary/MySwiftLibrary/JExtractSwiftPlugin/swift-java-jni-exports.map +``` + +> Tip: Adjust the `--version-script` path to match your package name and target name. Run `find .build/plugins/outputs -name swift-java-jni-exports.map` after the first build if you are unsure of the exact path. diff --git a/Sources/SwiftJavaMacros/ImplementsJavaMacro.swift b/Sources/SwiftJavaMacros/ImplementsJavaMacro.swift index 02a784bec..2770a490c 100644 --- a/Sources/SwiftJavaMacros/ImplementsJavaMacro.swift +++ b/Sources/SwiftJavaMacros/ImplementsJavaMacro.swift @@ -209,8 +209,11 @@ extension JavaImplementationMacro: PeerMacro { exposedMembers.append( """ + #if compiler(>=6.3) + @used + #endif @_cdecl(\(literal: cName)) - func \(context.makeUniqueName(swiftName))(\(raw: cParameters.map{ $0.description }.joined(separator: ", ")))\(raw: cReturnType) { + public func \(context.makeUniqueName(swiftName))(\(raw: cParameters.map{ $0.description }.joined(separator: ", ")))\(raw: cReturnType) { \(body) } """ diff --git a/Sources/SwiftJavaTool/Commands/JExtractCommand.swift b/Sources/SwiftJavaTool/Commands/JExtractCommand.swift index 1afd2cc27..f03af3499 100644 --- a/Sources/SwiftJavaTool/Commands/JExtractCommand.swift +++ b/Sources/SwiftJavaTool/Commands/JExtractCommand.swift @@ -98,6 +98,17 @@ extension SwiftJava { @Option(help: "If specified, JExtract will output to this file a list of paths to all generated Java source files") var generatedJavaSourcesListFileOutput: String? + + @Option( + help: """ + If specified, JExtract (JNI mode) will write a linker version script to this path. \ + The file lists every generated JNI @_cdecl entry-point symbol as a global export \ + and hides all other symbols with local: *, enabling dead-code elimination of \ + unreachable Swift code: + -Xlinker --version-script= + """ + ) + var linkerExportListOutput: String? } } @@ -116,6 +127,7 @@ extension SwiftJava.JExtractCommand { configure(&config.memoryManagementMode, overrideWith: self.memoryManagementMode) configure(&config.asyncFuncMode, overrideWith: self.asyncFuncMode) configure(&config.generatedJavaSourcesListFileOutput, overrideWith: self.generatedJavaSourcesListFileOutput) + configure(&config.linkerExportListOutput, overrideWith: self.linkerExportListOutput) try checkModeCompatibility(config: config) diff --git a/Tests/JExtractSwiftTests/JNI/JNICDeclAttributesTests.swift b/Tests/JExtractSwiftTests/JNI/JNICDeclAttributesTests.swift new file mode 100644 index 000000000..9ac42d5ac --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNICDeclAttributesTests.swift @@ -0,0 +1,56 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import JExtractSwiftLib +import Testing + +@Suite +struct JNICDeclAttributesTests { + + @Test + func globalFunc_hasUsedAttribute() throws { + try assertOutput( + input: "public func hello()", + .jni, + .swift, + expectedChunks: [ + """ + #if compiler(>=6.3) + @used + #endif + @_cdecl("Java_com_example_swift_SwiftModule__00024hello__") + ... + """ + ] + ) + } + + @Test + func globalFuncWithArgs_hasUsedAttribute() throws { + try assertOutput( + input: "public func add(a: Int64, b: Int64) -> Int64", + .jni, + .swift, + expectedChunks: [ + """ + #if compiler(>=6.3) + @used + #endif + @_cdecl("Java_com_example_swift_SwiftModule__00024add__JJ") + ... + """ + ] + ) + } +} diff --git a/Tests/SwiftJavaMacrosTests/JavaImplementationMacroTests.swift b/Tests/SwiftJavaMacrosTests/JavaImplementationMacroTests.swift index 2019acd67..de51cabfa 100644 --- a/Tests/SwiftJavaMacrosTests/JavaImplementationMacroTests.swift +++ b/Tests/SwiftJavaMacrosTests/JavaImplementationMacroTests.swift @@ -44,8 +44,11 @@ class JavaImplementationMacroTests: XCTestCase { } } + #if compiler(>=6.3) + @used + #endif @_cdecl("Java_org_swift_example_Hello_1World_test_1method") - func __macro_local_11test_methodfMu_(environment: UnsafeMutablePointer!, thisObj: jobject) -> Int32.JNIType { + public func __macro_local_11test_methodfMu_(environment: UnsafeMutablePointer!, thisObj: jobject) -> Int32.JNIType { let obj = HelloWorld(javaThis: thisObj, environment: environment!) return obj.test_method() .getJNILocalRefValue(in: environment) @@ -74,8 +77,11 @@ class JavaImplementationMacroTests: XCTestCase { } } + #if compiler(>=6.3) + @used + #endif @_cdecl("Java_com_example_test_MyClass_simpleMethod") - func __macro_local_12simpleMethodfMu_(environment: UnsafeMutablePointer!, thisObj: jobject) -> Int32.JNIType { + public func __macro_local_12simpleMethodfMu_(environment: UnsafeMutablePointer!, thisObj: jobject) -> Int32.JNIType { let obj = MyClass(javaThis: thisObj, environment: environment!) return obj.simpleMethod() .getJNILocalRefValue(in: environment) @@ -104,8 +110,11 @@ class JavaImplementationMacroTests: XCTestCase { } } + #if compiler(>=6.3) + @used + #endif @_cdecl("Java_org_example_Utils_static_1helper") - func __macro_local_13static_helperfMu_(environment: UnsafeMutablePointer!, thisClass: jclass) -> String.JNIType { + public func __macro_local_13static_helperfMu_(environment: UnsafeMutablePointer!, thisClass: jclass) -> String.JNIType { return Utils.static_helper(environment: environment) .getJNILocalRefValue(in: environment) } @@ -141,15 +150,21 @@ class JavaImplementationMacroTests: XCTestCase { } } + #if compiler(>=6.3) + @used + #endif @_cdecl("Java_test_Class_1With_1Underscores_method_1one") - func __macro_local_10method_onefMu_(environment: UnsafeMutablePointer!, thisObj: jobject) -> Int32.JNIType { + public func __macro_local_10method_onefMu_(environment: UnsafeMutablePointer!, thisObj: jobject) -> Int32.JNIType { let obj = ClassWithUnderscores(javaThis: thisObj, environment: environment!) return obj.method_one() .getJNILocalRefValue(in: environment) } + #if compiler(>=6.3) + @used + #endif @_cdecl("Java_test_Class_1With_1Underscores_method_1two") - func __macro_local_10method_twofMu_(environment: UnsafeMutablePointer!, thisObj: jobject) -> Int32.JNIType { + public func __macro_local_10method_twofMu_(environment: UnsafeMutablePointer!, thisObj: jobject) -> Int32.JNIType { let obj = ClassWithUnderscores(javaThis: thisObj, environment: environment!) return obj.method_two() .getJNILocalRefValue(in: environment) @@ -186,14 +201,20 @@ class JavaImplementationMacroTests: XCTestCase { } } + #if compiler(>=6.3) + @used + #endif @_cdecl("Java_org_swift_swiftkit_core_collections_SwiftDictionaryMap__00024size") - func __macro_local_5_sizefMu_(environment: UnsafeMutablePointer!, thisClass: jclass, pointer: Int64.JNIType) -> Int32.JNIType { + public func __macro_local_5_sizefMu_(environment: UnsafeMutablePointer!, thisClass: jclass, pointer: Int64.JNIType) -> Int32.JNIType { return SwiftDictionaryMapJava._size(environment: environment, pointer: Int64(fromJNI: pointer, in: environment!)) .getJNILocalRefValue(in: environment) } + #if compiler(>=6.3) + @used + #endif @_cdecl("Java_org_swift_swiftkit_core_collections_SwiftDictionaryMap__00024destroy") - func __macro_local_8_destroyfMu_(environment: UnsafeMutablePointer!, thisClass: jclass, pointer: Int64.JNIType) { + public func __macro_local_8_destroyfMu_(environment: UnsafeMutablePointer!, thisClass: jclass, pointer: Int64.JNIType) { return SwiftDictionaryMapJava._destroy(environment: environment, pointer: Int64(fromJNI: pointer, in: environment!)) } """, @@ -220,8 +241,11 @@ class JavaImplementationMacroTests: XCTestCase { } } + #if compiler(>=6.3) + @used + #endif @_cdecl("Java_org_example_Processor_process_1data") - func __macro_local_12process_datafMu_(environment: UnsafeMutablePointer!, thisObj: jobject) { + public func __macro_local_12process_datafMu_(environment: UnsafeMutablePointer!, thisObj: jobject) { let obj = Processor(javaThis: thisObj, environment: environment!) return obj.process_data() }