Skip to content

Commit

Permalink
iOS avoid redundant compositing (#813)
Browse files Browse the repository at this point in the history
## Proposed Changes

Only use `presentWithTransaction` and transparent `CAMetalLayer` when
UIKit interop is active.

## Testing

Test: N/A

## Issues Fixed

Fixes: occasional frame drops reported in
JetBrains/compose-multiplatform#3605 when iOS
compositor fails to meet frame deadline because of going slow off-screen
rendering path, instead of direct-to-screen.

Composited | Direct
---|---

![Composited](https://github.com/JetBrains/compose-multiplatform-core/assets/4167681/130ec472-1675-4eda-b2cc-fa1e1e84ec1d)
|
![Direct](https://github.com/JetBrains/compose-multiplatform-core/assets/4167681/65ebf09a-7896-4354-9c64-9c0e547a87f6)
  • Loading branch information
elijah-semyonov committed Sep 19, 2023
1 parent e5cbf0d commit 7fd0675
Show file tree
Hide file tree
Showing 6 changed files with 173 additions and 84 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,62 @@ package androidx.compose.ui.interop
import androidx.compose.runtime.staticCompositionLocalOf
import platform.Foundation.NSLock

internal enum class UIKitInteropState {
BEGAN, UNCHANGED, ENDED
}

internal enum class UIKitInteropViewHierarchyChange {
VIEW_ADDED,
VIEW_REMOVED
}

/**
* Lambda containing changes to UIKit objects, which can be synchronized within [CATransaction]
*/
typealias UIKitInteropAction = () -> Unit

internal interface UIKitInteropTransaction {
val actions: List<UIKitInteropAction>
val state: UIKitInteropState

companion object {
val empty = object : UIKitInteropTransaction {
override val actions: List<UIKitInteropAction>
get() = listOf()

override val state: UIKitInteropState
get() = UIKitInteropState.UNCHANGED
}
}
}

internal fun UIKitInteropTransaction.isEmpty() = actions.isEmpty() && state == UIKitInteropState.UNCHANGED
internal fun UIKitInteropTransaction.isNotEmpty() = !isEmpty()

private class UIKitInteropMutableTransaction: UIKitInteropTransaction {
override val actions = mutableListOf<UIKitInteropAction>()
override var state = UIKitInteropState.UNCHANGED
set(value) {
field = when (value) {
UIKitInteropState.UNCHANGED -> error("Can't assign UNCHANGED value explicitly")
UIKitInteropState.BEGAN -> {
when (field) {
UIKitInteropState.BEGAN -> error("Can't assign BEGAN twice in the same transaction")
UIKitInteropState.UNCHANGED -> value
UIKitInteropState.ENDED -> UIKitInteropState.UNCHANGED
}
}
UIKitInteropState.ENDED -> {
when (field) {
UIKitInteropState.BEGAN -> UIKitInteropState.UNCHANGED
UIKitInteropState.UNCHANGED -> value
UIKitInteropState.ENDED -> error("Can't assign ENDED twice in the same transaction")
}
}
}
}
}

/**
* Class which can be used to add actions related to UIKit objects to be executed in sync with compose rendering,
* Addding deferred actions is threadsafe, but they will be executed in the order of their submission, and on the main thread.
Expand All @@ -27,29 +83,52 @@ internal class UIKitInteropContext(
val requestRedraw: () -> Unit
) {
private val lock: NSLock = NSLock()
private val actions = mutableListOf<() -> Unit>()
private var transaction = UIKitInteropMutableTransaction()

/**
* Number of views, created by interop API and present in current view hierarchy
*/
private var viewsCount = 0
set(value) {
require(value >= 0)

field = value
}

/**
* Add lambda to a list of commands which will be executed later in the same CATransaction, when the next rendered Compose frame is presented
*/
fun deferAction(action: () -> Unit) {
fun deferAction(hierarchyChange: UIKitInteropViewHierarchyChange? = null, action: () -> Unit) {
requestRedraw()

lock.doLocked {
actions.add(action)
if (hierarchyChange == UIKitInteropViewHierarchyChange.VIEW_ADDED) {
if (viewsCount == 0) {
transaction.state = UIKitInteropState.BEGAN
}
viewsCount += 1
}

transaction.actions.add(action)

if (hierarchyChange == UIKitInteropViewHierarchyChange.VIEW_REMOVED) {
viewsCount -= 1
if (viewsCount == 0) {
transaction.state = UIKitInteropState.ENDED
}
}
}
}

/**
* Return a copy of the list of [actions] and clear it.
* Return an object containing pending changes and reset internal storage
*/
internal fun getActionsAndClear(): List<() -> Unit> {
return lock.doLocked {
val result = actions.toList()
actions.clear()
internal fun retrieve(): UIKitInteropTransaction =
lock.doLocked {
val result = transaction
transaction = UIKitInteropMutableTransaction()
result
}
}
}

private inline fun <T> NSLock.doLocked(block: () -> T): T {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,13 @@ import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.round
import kotlinx.atomicfu.atomic
import kotlinx.cinterop.CValue
import kotlinx.cinterop.useContents
import platform.CoreGraphics.CGRect
import platform.CoreGraphics.CGRectMake
import platform.Foundation.NSThread
import platform.UIKit.NSStringFromCGRect
import platform.UIKit.UIColor
import platform.UIKit.UIView
import platform.darwin.dispatch_async
import platform.darwin.dispatch_get_main_queue

private val STUB_CALLBACK_WITH_RECEIVER: Any.() -> Unit = {}
private val NoOpUpdate: UIView.() -> Unit = STUB_CALLBACK_WITH_RECEIVER
Expand Down Expand Up @@ -122,17 +122,19 @@ fun <T : UIView> UIKitView(

DisposableEffect(Unit) {
componentInfo.component = factory()
componentInfo.updater = Updater(componentInfo.component, update, interopContext::deferAction)
componentInfo.updater = Updater(componentInfo.component, update) {
interopContext.deferAction(action = it)
}

interopContext.deferAction {
interopContext.deferAction(UIKitInteropViewHierarchyChange.VIEW_ADDED) {
componentInfo.container = UIView().apply {
addSubview(componentInfo.component)
}
root.insertSubview(componentInfo.container, 0)
}

onDispose {
interopContext.deferAction {
interopContext.deferAction(UIKitInteropViewHierarchyChange.VIEW_REMOVED) {
componentInfo.container.removeFromSuperview()
componentInfo.updater.dispose()
onRelease(componentInfo.component)
Expand Down Expand Up @@ -234,4 +236,4 @@ private class Updater<T : UIView>(
snapshotObserver.clear()
isDisposed = true
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ import androidx.compose.ui.input.pointer.toCompose
import androidx.compose.ui.interop.LocalLayerContainer
import androidx.compose.ui.interop.LocalUIKitInteropContext
import androidx.compose.ui.interop.LocalUIViewController
import androidx.compose.ui.interop.UIKitInteropAction
import androidx.compose.ui.interop.UIKitInteropContext
import androidx.compose.ui.interop.UIKitInteropTransaction
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.input.PlatformTextInputService
import androidx.compose.ui.uikit.*
Expand Down Expand Up @@ -131,6 +133,7 @@ private class AttachedComposeContext(
view.bottomAnchor.constraintEqualToAnchor(parentView.bottomAnchor)
)
}

fun dispose() {
scene.close()
view.dispose()
Expand All @@ -146,9 +149,11 @@ internal actual class ComposeWindow : UIViewController {
private var isInsideSwiftUI = false
private val safeAreaState = mutableStateOf(IOSInsets())
private val layoutMarginsState = mutableStateOf(IOSInsets())
private val interopContext = UIKitInteropContext(requestRedraw = {
attachedComposeContext?.view?.needRedraw()
})
private val interopContext = UIKitInteropContext(
requestRedraw = {
attachedComposeContext?.view?.needRedraw()
}
)

/*
* Initial value is arbitarily chosen to avoid propagating invalid value logic
Expand Down Expand Up @@ -634,8 +639,8 @@ internal actual class ComposeWindow : UIViewController {
)
}

override fun retrieveCATransactionCommands(): List<() -> Unit> =
interopContext.getActionsAndClear()
override fun retrieveInteropTransaction(): UIKitInteropTransaction =
interopContext.retrieve()

override fun draw(surface: Surface, targetTimestamp: NSTimeInterval) {
// The calculation is split in two instead of
Expand All @@ -644,7 +649,8 @@ internal actual class ComposeWindow : UIViewController {
val integral = floor(targetTimestamp)
val fractional = targetTimestamp - integral
val secondsToNanos = 1_000_000_000L
val nanos = integral.roundToLong() * secondsToNanos + (fractional * 1e9).roundToLong()
val nanos =
integral.roundToLong() * secondsToNanos + (fractional * 1e9).roundToLong()

scene.render(surface.canvas, nanos)
}
Expand Down Expand Up @@ -679,26 +685,26 @@ internal actual class ComposeWindow : UIViewController {
}

private fun UIViewController.checkIfInsideSwiftUI(): Boolean {
var parent = parentViewController

while (parent != null) {
val isUIHostingController = parent.`class`()?.let {
val className = NSStringFromClass(it)
// SwiftUI UIHostingController has mangled name depending on generic instantiation type,
// It always contains UIHostingController substring though
return className.contains("UIHostingController")
} ?: false

if (isUIHostingController) {
return true
}

parent = parent.parentViewController
var parent = parentViewController

while (parent != null) {
val isUIHostingController = parent.`class`()?.let {
val className = NSStringFromClass(it)
// SwiftUI UIHostingController has mangled name depending on generic instantiation type,
// It always contains UIHostingController substring though
return className.contains("UIHostingController")
} ?: false

if (isUIHostingController) {
return true
}

return false
parent = parent.parentViewController
}

return false
}

private fun UIUserInterfaceStyle.asComposeSystemTheme(): SystemTheme {
return when (this) {
UIUserInterfaceStyle.UIUserInterfaceStyleLight -> SystemTheme.Light
Expand Down

0 comments on commit 7fd0675

Please sign in to comment.