Browse Source
* Simplify resource management for iOS Introduces new a new task 'sync<FRAMEWORK_CLASSIFIER>ComposeIosResources', which collects resources from all source sets, included in iOS targets. With this change: * CocoaPods integration does not require any configuration or calling 'pod install' after changing resources. * Important: existing projects need to remove 'extraSpecAttributes["resources"] = ...' from build scripts, and rerun `./gradlew podInstall` once! * Without CocoaPods, the resource directory should be added to XCode build phases once. Resolves #3073 Resolves #3113 Resolves #3066ivan.matkov/skiko-dsl v1.5.0-dev1104
Alexey Tsvetkov
1 year ago
committed by
GitHub
22 changed files with 554 additions and 109 deletions
@ -1,6 +0,0 @@
|
||||
target 'iosApp' do |
||||
use_frameworks! |
||||
platform :ios, '14.1' |
||||
pod 'shared', :path => '../shared' |
||||
project 'iosApp.xcodeproj' |
||||
end |
@ -0,0 +1,56 @@
|
||||
/* |
||||
* Copyright 2020-2023 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.experimental.uikit.internal.resources |
||||
|
||||
import org.gradle.api.provider.Property |
||||
import org.gradle.api.provider.SetProperty |
||||
import org.gradle.api.tasks.Input |
||||
import java.io.ObjectInputStream |
||||
import java.io.ObjectOutputStream |
||||
import java.io.Serializable |
||||
|
||||
internal abstract class IosTargetResources : Serializable { |
||||
@get:Input |
||||
abstract val name: Property<String> |
||||
|
||||
@get:Input |
||||
abstract val konanTarget: Property<String> |
||||
|
||||
@get:Input |
||||
abstract val dirs: SetProperty<String> |
||||
|
||||
@Suppress("unused") // used by Gradle Configuration Cache |
||||
fun readObject(input: ObjectInputStream) { |
||||
name.set(input.readUTF()) |
||||
konanTarget.set(input.readUTF()) |
||||
dirs.set(input.readUTFStrings()) |
||||
} |
||||
|
||||
@Suppress("unused") // used by Gradle Configuration Cache |
||||
fun writeObject(output: ObjectOutputStream) { |
||||
output.writeUTF(name.get()) |
||||
output.writeUTF(konanTarget.get()) |
||||
output.writeUTFStrings(dirs.get()) |
||||
} |
||||
|
||||
private fun ObjectOutputStream.writeUTFStrings(collection: Collection<String>) { |
||||
writeInt(collection.size) |
||||
collection.forEach { writeUTF(it) } |
||||
} |
||||
|
||||
private fun ObjectInputStream.readUTFStrings(): Set<String> { |
||||
val size = readInt() |
||||
return LinkedHashSet<String>(size).apply { |
||||
repeat(size) { |
||||
add(readUTF()) |
||||
} |
||||
} |
||||
} |
||||
|
||||
companion object { |
||||
private const val serialVersionUID: Long = 0 |
||||
} |
||||
} |
@ -0,0 +1,178 @@
|
||||
/* |
||||
* Copyright 2020-2023 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.experimental.uikit.internal.resources |
||||
|
||||
import org.gradle.api.Project |
||||
import org.gradle.api.file.Directory |
||||
import org.gradle.api.provider.Provider |
||||
import org.gradle.api.tasks.TaskContainer |
||||
import org.jetbrains.compose.experimental.uikit.internal.utils.IosGradleProperties |
||||
import org.jetbrains.compose.experimental.uikit.internal.utils.asIosNativeTargetOrNull |
||||
import org.jetbrains.compose.experimental.uikit.internal.utils.cocoapodsExt |
||||
import org.jetbrains.compose.experimental.uikit.internal.utils.withCocoapodsPlugin |
||||
import org.jetbrains.compose.experimental.uikit.tasks.SyncComposeResourcesForIosTask |
||||
import org.jetbrains.compose.internal.utils.joinLowerCamelCase |
||||
import org.jetbrains.compose.internal.utils.new |
||||
import org.jetbrains.compose.internal.utils.registerOrConfigure |
||||
import org.jetbrains.compose.internal.utils.uppercaseFirstChar |
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension |
||||
import org.jetbrains.kotlin.gradle.plugin.mpp.Framework |
||||
import org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType |
||||
import java.io.File |
||||
|
||||
internal fun Project.configureSyncTask(mppExt: KotlinMultiplatformExtension) { |
||||
if (!IosGradleProperties.syncResources(providers).get()) return |
||||
|
||||
with (SyncIosResourcesContext(project, mppExt)) { |
||||
configureSyncResourcesTasks() |
||||
configureCocoapodsResourcesAttribute() |
||||
} |
||||
} |
||||
|
||||
private class SyncIosResourcesContext( |
||||
val project: Project, |
||||
val mppExt: KotlinMultiplatformExtension |
||||
) { |
||||
fun syncDirFor(framework: Framework): Provider<Directory> { |
||||
val providers = framework.project.providers |
||||
return if (framework.isCocoapodsFramework) { |
||||
project.layout.buildDirectory.dir("compose/ios/${framework.baseName}/compose-resources/") |
||||
} else { |
||||
providers.environmentVariable("BUILT_PRODUCTS_DIR") |
||||
.zip(providers.environmentVariable("CONTENTS_FOLDER_PATH")) { builtProductsDir, contentsFolderPath -> |
||||
File(builtProductsDir) |
||||
.resolve(contentsFolderPath) |
||||
.resolve("compose-resources") |
||||
.canonicalPath |
||||
}.flatMap { |
||||
framework.project.objects.directoryProperty().apply { set(File(it)) } |
||||
} |
||||
} |
||||
} |
||||
|
||||
|
||||
fun configureEachIosFramework(fn: (Framework) -> Unit) { |
||||
mppExt.targets.all { target -> |
||||
target.asIosNativeTargetOrNull()?.let { iosTarget -> |
||||
iosTarget.binaries.withType(Framework::class.java).configureEach { framework -> |
||||
fn(framework) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private const val RESOURCES_SPEC_ATTR = "resources" |
||||
private fun SyncIosResourcesContext.configureCocoapodsResourcesAttribute() { |
||||
project.withCocoapodsPlugin { |
||||
project.gradle.taskGraph.whenReady { |
||||
val cocoapodsExt = mppExt.cocoapodsExt |
||||
val specAttributes = cocoapodsExt.extraSpecAttributes |
||||
val resourcesSpec = specAttributes[RESOURCES_SPEC_ATTR] |
||||
if (!resourcesSpec.isNullOrBlank()) { |
||||
project.logger.warn("Warning: kotlin.cocoapods.extraSpecAttributes[\"resources\"] is ignored by Compose Multiplatform's resource synchronization for iOS") |
||||
} |
||||
cocoapodsExt.framework { |
||||
val syncDir = syncDirFor(this).get().asFile |
||||
specAttributes[RESOURCES_SPEC_ATTR] = "['${syncDir.relativeTo(project.projectDir).path}']" |
||||
project.tasks.named("podInstall").configure { |
||||
it.doFirst { |
||||
syncDir.mkdirs() |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun SyncIosResourcesContext.configureSyncResourcesTasks() { |
||||
val lazyTasksDependencies = LazyTasksDependencyConfigurator(project.tasks) |
||||
configureEachIosFramework { framework -> |
||||
val frameworkClassifier = framework.namePrefix.uppercaseFirstChar() |
||||
val syncResourcesTaskName = "sync${frameworkClassifier}ComposeResourcesForIos" |
||||
val syncTask = framework.project.tasks.registerOrConfigure<SyncComposeResourcesForIosTask>(syncResourcesTaskName) { |
||||
outputDir.set(syncDirFor(framework)) |
||||
iosTargets.add(iosTargetResourcesProvider(framework)) |
||||
} |
||||
with (lazyTasksDependencies) { |
||||
if (framework.isCocoapodsFramework) { |
||||
"syncFramework".lazyDependsOn(syncTask.name) |
||||
} else { |
||||
"embedAndSign${frameworkClassifier}AppleFrameworkForXcode".lazyDependsOn(syncTask.name) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private val Framework.isCocoapodsFramework: Boolean |
||||
get() = name.startsWith("pod") |
||||
|
||||
private val Framework.namePrefix: String |
||||
get() = extractPrefixFromBinaryName( |
||||
name, |
||||
buildType, |
||||
outputKind.taskNameClassifier |
||||
) |
||||
|
||||
private fun extractPrefixFromBinaryName(name: String, buildType: NativeBuildType, outputKindClassifier: String): String { |
||||
val suffix = joinLowerCamelCase(buildType.getName(), outputKindClassifier) |
||||
return if (name == suffix) |
||||
"" |
||||
else |
||||
name.substringBeforeLast(suffix.uppercaseFirstChar()) |
||||
} |
||||
|
||||
private fun iosTargetResourcesProvider(framework: Framework): Provider<IosTargetResources> { |
||||
val kotlinTarget = framework.target |
||||
val project = framework.project |
||||
return project.provider { |
||||
val resourceDirs = framework.compilation.allKotlinSourceSets |
||||
.flatMap { sourceSet -> |
||||
sourceSet.resources.srcDirs.map { it.canonicalPath } |
||||
} |
||||
project.objects.new<IosTargetResources>().apply { |
||||
name.set(kotlinTarget.name) |
||||
konanTarget.set(kotlinTarget.konanTarget.name) |
||||
dirs.set(resourceDirs) |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Ensures, that a dependency between tasks is set up, |
||||
* when a dependent task (fromTask) is created, while avoiding eager configuration. |
||||
*/ |
||||
private class LazyTasksDependencyConfigurator(private val tasks: TaskContainer) { |
||||
private val existingDependencies = HashSet<Pair<String, String>>() |
||||
private val requestedDependencies = HashMap<String, MutableSet<String>>() |
||||
|
||||
init { |
||||
tasks.configureEach { fromTask -> |
||||
val onTasks = requestedDependencies.remove(fromTask.name) ?: return@configureEach |
||||
for (onTaskName in onTasks) { |
||||
val dependency = fromTask.name to onTaskName |
||||
if (existingDependencies.add(dependency)) { |
||||
fromTask.dependsOn(onTaskName) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun String.lazyDependsOn(dependencyTask: String) { |
||||
val dependingTask = this |
||||
val dependency = dependingTask to dependencyTask |
||||
if (dependency in existingDependencies) return |
||||
|
||||
if (dependingTask in tasks.names) { |
||||
tasks.named(dependingTask).configure { it.dependsOn(dependencyTask) } |
||||
existingDependencies.add(dependency) |
||||
} else { |
||||
requestedDependencies |
||||
.getOrPut(dependingTask) { HashSet() } |
||||
.add(dependencyTask) |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,38 @@
|
||||
/* |
||||
* Copyright 2020-2023 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.experimental.uikit.internal.resources |
||||
|
||||
import org.jetbrains.kotlin.konan.target.KonanTarget |
||||
|
||||
// based on AppleSdk.kt from Kotlin Gradle Plugin |
||||
// See https://github.com/JetBrains/kotlin/blob/142421da5b966049b4eab44ce6856eb172cf122a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/mpp/apple/AppleSdk.kt |
||||
internal fun determineIosKonanTargetsFromEnv(platform: String, archs: List<String>): List<KonanTarget> { |
||||
val targets: MutableSet<KonanTarget> = mutableSetOf() |
||||
|
||||
when { |
||||
platform.startsWith("iphoneos") -> { |
||||
targets.addAll(archs.map { arch -> |
||||
when (arch) { |
||||
"arm64", "arm64e" -> KonanTarget.IOS_ARM64 |
||||
"armv7", "armv7s" -> KonanTarget.IOS_ARM32 |
||||
else -> error("Unknown iOS device arch: '$arch'") |
||||
} |
||||
}) |
||||
} |
||||
platform.startsWith("iphonesimulator") -> { |
||||
targets.addAll(archs.map { arch -> |
||||
when (arch) { |
||||
"arm64", "arm64e" -> KonanTarget.IOS_SIMULATOR_ARM64 |
||||
"x86_64" -> KonanTarget.IOS_X64 |
||||
else -> error("Unknown iOS simulator arch: '$arch'") |
||||
} |
||||
}) |
||||
} |
||||
else -> error("Unknown iOS platform: '$platform'") |
||||
} |
||||
|
||||
return targets.toList() |
||||
} |
@ -0,0 +1,18 @@
|
||||
/* |
||||
* Copyright 2020-2023 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.experimental.uikit.internal.utils |
||||
|
||||
import org.gradle.api.provider.Provider |
||||
import org.gradle.api.provider.ProviderFactory |
||||
import org.jetbrains.compose.internal.utils.findProperty |
||||
import org.jetbrains.compose.internal.utils.toBooleanProvider |
||||
|
||||
internal object IosGradleProperties { |
||||
const val SYNC_RESOURCES_PROPERTY = "org.jetbrains.compose.ios.resources.sync" |
||||
|
||||
fun syncResources(providers: ProviderFactory): Provider<Boolean> = |
||||
providers.findProperty(SYNC_RESOURCES_PROPERTY).toBooleanProvider(true) |
||||
} |
@ -0,0 +1,26 @@
|
||||
/* |
||||
* Copyright 2020-2023 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.experimental.uikit.internal.utils |
||||
|
||||
import org.gradle.api.Project |
||||
import org.gradle.api.plugins.ExtensionAware |
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension |
||||
import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension |
||||
|
||||
private const val COCOAPODS_PLUGIN_ID = "org.jetbrains.kotlin.native.cocoapods" |
||||
internal fun Project.withCocoapodsPlugin(fn: () -> Unit) { |
||||
project.plugins.withId(COCOAPODS_PLUGIN_ID) { |
||||
fn() |
||||
} |
||||
} |
||||
|
||||
internal val KotlinMultiplatformExtension.cocoapodsExt: CocoapodsExtension |
||||
get() { |
||||
val extensionAware = (this as? ExtensionAware) ?: error("KotlinMultiplatformExtension is not ExtensionAware") |
||||
val extName = "cocoapods" |
||||
val ext = extensionAware.extensions.findByName(extName) ?: error("KotlinMultiplatformExtension does not contain '$extName' extension") |
||||
return ext as CocoapodsExtension |
||||
} |
@ -0,0 +1,22 @@
|
||||
/* |
||||
* Copyright 2020-2023 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.experimental.uikit.internal.utils |
||||
|
||||
import org.jetbrains.kotlin.gradle.plugin.KotlinTarget |
||||
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget |
||||
import org.jetbrains.kotlin.konan.target.KonanTarget |
||||
|
||||
internal fun KotlinNativeTarget.isIosSimulatorTarget(): Boolean = |
||||
konanTarget === KonanTarget.IOS_X64 || konanTarget === KonanTarget.IOS_SIMULATOR_ARM64 |
||||
|
||||
internal fun KotlinNativeTarget.isIosDeviceTarget(): Boolean = |
||||
konanTarget === KonanTarget.IOS_ARM64 || konanTarget === KonanTarget.IOS_ARM32 |
||||
|
||||
internal fun KotlinNativeTarget.isIosTarget(): Boolean = |
||||
isIosSimulatorTarget() || isIosDeviceTarget() |
||||
|
||||
internal fun KotlinTarget.asIosNativeTargetOrNull(): KotlinNativeTarget? = |
||||
(this as? KotlinNativeTarget)?.takeIf { it.isIosTarget() } |
@ -0,0 +1,100 @@
|
||||
/* |
||||
* Copyright 2020-2023 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.experimental.uikit.tasks |
||||
|
||||
import org.gradle.api.file.DirectoryProperty |
||||
import org.gradle.api.file.FileCollection |
||||
import org.gradle.api.provider.Provider |
||||
import org.gradle.api.provider.SetProperty |
||||
import org.gradle.api.tasks.* |
||||
import org.jetbrains.compose.experimental.uikit.internal.resources.determineIosKonanTargetsFromEnv |
||||
import org.jetbrains.compose.experimental.uikit.internal.resources.IosTargetResources |
||||
import org.jetbrains.compose.internal.utils.clearDirs |
||||
import java.io.File |
||||
import kotlin.io.path.Path |
||||
import kotlin.io.path.pathString |
||||
import kotlin.io.path.relativeTo |
||||
|
||||
abstract class SyncComposeResourcesForIosTask : AbstractComposeIosTask() { |
||||
private fun missingTargetEnvAttributeError(attribute: String): Provider<Nothing> = |
||||
providers.provider { |
||||
error( |
||||
"Could not infer iOS target $attribute. Make sure to build " + |
||||
"via XCode (directly or via Kotlin Multiplatform Mobile plugin for Android Studio)") |
||||
} |
||||
|
||||
@get:Input |
||||
val xcodeTargetPlatform: Provider<String> = |
||||
providers.gradleProperty("compose.ios.resources.platform") |
||||
.orElse(providers.environmentVariable("PLATFORM_NAME")) |
||||
.orElse(missingTargetEnvAttributeError("platform")) |
||||
|
||||
@get:Input |
||||
val xcodeTargetArchs: Provider<List<String>> = |
||||
providers.gradleProperty("compose.ios.resources.archs") |
||||
.orElse(providers.environmentVariable("ARCHS")) |
||||
.orElse(missingTargetEnvAttributeError("architectures")) |
||||
.map { it.split(",", " ").filter { it.isNotBlank() } } |
||||
|
||||
@get:Input |
||||
internal val iosTargets: SetProperty<IosTargetResources> = objects.setProperty(IosTargetResources::class.java) |
||||
|
||||
@get:PathSensitive(PathSensitivity.ABSOLUTE) |
||||
@get:InputFiles |
||||
val resourceFiles: Provider<FileCollection> = xcodeTargetPlatform.zip(xcodeTargetArchs, ::Pair) |
||||
.map { (xcodeTargetPlatform, xcodeTargetArchs) -> |
||||
val allResources = objects.fileCollection() |
||||
val activeKonanTargets = determineIosKonanTargetsFromEnv(xcodeTargetPlatform, xcodeTargetArchs) |
||||
.mapTo(HashSet()) { it.name } |
||||
val dirsToInclude = iosTargets.get() |
||||
.filter { it.konanTarget.get() in activeKonanTargets } |
||||
.flatMapTo(HashSet()) { it.dirs.get() } |
||||
for (dirPath in dirsToInclude) { |
||||
val fileTree = objects.fileTree().apply { |
||||
setDir(layout.projectDirectory.dir(dirPath)) |
||||
include("**/*") |
||||
} |
||||
allResources.from(fileTree) |
||||
} |
||||
allResources |
||||
} |
||||
|
||||
@get:OutputDirectory |
||||
val outputDir: DirectoryProperty = objects.directoryProperty() |
||||
|
||||
@TaskAction |
||||
fun run() { |
||||
val outputDir = outputDir.get().asFile |
||||
fileOperations.clearDirs(outputDir) |
||||
val allResourceDirs = iosTargets.get().flatMapTo(HashSet()) { it.dirs.get().map { Path(it).toAbsolutePath() } } |
||||
|
||||
fun copyFileToOutputDir(file: File) { |
||||
for (dir in allResourceDirs) { |
||||
val path = file.toPath().toAbsolutePath() |
||||
if (path.startsWith(dir)) { |
||||
val targetFile = outputDir.resolve(path.relativeTo(dir).pathString) |
||||
file.copyTo(targetFile, overwrite = true) |
||||
return |
||||
} |
||||
} |
||||
|
||||
error( |
||||
buildString { |
||||
appendLine("Resource file '$file' does not belong to a known resource directory:") |
||||
allResourceDirs.forEach { |
||||
appendLine("* $it") |
||||
} |
||||
} |
||||
) |
||||
} |
||||
|
||||
val resourceFiles = resourceFiles.get().files |
||||
for (file in resourceFiles) { |
||||
copyFileToOutputDir(file) |
||||
} |
||||
logger.info("Synced Compose resource files. Copied ${resourceFiles.size} files to $outputDir") |
||||
} |
||||
} |
Loading…
Reference in new issue