dotnet / runtimelab

This repo is for experimentation and exploring new ideas that may or may not make it into the main dotnet/runtime repo.
MIT License
1.36k stars 188 forks source link

[Discussion] Generate C# bindings for SwiftUI framework #2594

Open kotlarmilos opened 1 month ago

kotlarmilos commented 1 month ago

Objective

The goal is to generate bindings for SwiftUI APIs that can be utilized in .NET mobile applications, enabling .NET applications to call into Swift components. To achieve this, we define a dev template and attempt to implement it; through this effort, we will identify the Swift language constructs required to project in C#. Once identified, these constructs need to be designed and implemented within the projection tooling. The MAUI framework is based on UIKit Objective-C interop, and we want to integrate Swift interop with the existing interop. Later, we may decide to provide Swift bindings for UIKit, but it is beyond the scope at this stage.

Dev template

We will use a .NET MAUI application to discover requirements and validate design proposals. We will implement a dev template with a button, text, and image from SwiftUI framework. To add custom MAUI controls and views, a MAUI handlers will be created by implementing C# projections of the views integrated with UIView from UIKit framework using UIHostingController:

SwiftUI interop diagram

This code snippet illustrates adding custom MAUI handlers:

var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
    fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
    fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
})
.ConfigureMauiHandlers(handlers =>
{
    handlers.AddHandler(typeof(SimpleSwiftUIView), typeof(SimpleSwiftUIViewHandler));
});

return builder.Build();

This code snippet should instantiate a new SwiftUI view and return a UIView using UIHostingController:

public class SimpleSwiftUIViewHandler : ViewHandler<View, UIView>
{
    public SimpleSwiftUIViewHandler() : base(Mapper, CommandMapper)
    {
    }

    protected override UIView CreatePlatformView()
    {
        // Create a new SwiftUI view
        var swiftUIView = new CustomSwiftUIView();

        // Create a UIHostingController with the SwiftUI view
        var hostingController = new UIHostingController(swiftUIView);

        // Return the UIView from the hosting controller
        return hostingController.View;
    }
}

This is an example of the development template MAUI application we aim to implement: Simulator Screenshot - iPad Pro 13-inch (M4) (16GB) - iOS 17 5 - 2024-05-24 at 11 55 18


Projections

To implement the dev template, we need to project Text, Button, and Image types along with View protocol and UIHostingController class from SwiftUI framework. The following sections represent a general overview of constructs we should project, and their design will be discussed in separate design/implementation-related issues.

The SwiftUI framework declares the UI and behavior of an app on every platform. It can be integrated with the UIKit and AppKit frameworks by implementing the UIHostingController and NSHostingController classes. The framework declares the App and Scene protocols, which represent the structure of the app. A type that conforms to the App protocol, annotated with main, implements a static main function that serves as the entry point to the application. We aim to enable existing .NET applications to call Swift components, focusing on views and controls that can be utilized within the .NET ecosystem.

This is a sample mobile app in Swift utilizing SwiftUI framework:

@main
public struct LandmarksApp: App {
    public init() {}
    public var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Terms Some/Any are commonly used in the framework like presented below.

Conceptualizing the difference is to think of both some and any as "boxed" opaque types that conform to a protocol:

View

The View protocol from SwiftUI framework has a computed property var body: Body where Body is an associated type. Protocols in Swift can be projected as C# interfaces. Associated type doesn't have direct mapping in C# and could be represented with generics:

public interface View<T> where T : View<T>
{
    T Body { get; }
}

To illustrate difference between associated type and generics, here is an example:

// Protocol with associated type
protocol ATInterface {
    associatedtype T
    func GetType() -> T
}

// Protocol with geneics
protocol GInterface {
    func GetType<U>() -> U
}

func GetATInterface<T: ATInterface>(t: T) -> T.T {
    return t.GetType()
}

func GetGInterface<T: GInterface, U>(t: T) -> U {
    return t.GetType()
}

The corresponding LLVM-IR code:

define hidden swiftcc void @"$s12HelloLibrary14GetATInterface1t1TQzx_tAA0D0RzlF"(ptr noalias nocapture sret(%swift.opaque) %0, ptr noalias nocapture %1, ptr %T, ptr %T.ATInterface) #0 {
entry:
  %T1 = alloca ptr, align 8
  %t.debug = alloca ptr, align 8
  call void @llvm.memset.p0.i64(ptr align 8 %t.debug, i8 0, i64 8, i1 false)
  store ptr %T, ptr %T1, align 8
  store ptr %1, ptr %t.debug, align 8
  %2 = getelementptr inbounds ptr, ptr %T.ATInterface, i32 2
  %3 = load ptr, ptr %2, align 8, !invariant.load !65
  call swiftcc void %3(ptr noalias nocapture sret(%swift.opaque) %0, ptr noalias nocapture swiftself %1, ptr %T, ptr %T.ATInterface)
  ret void
}

define hidden swiftcc void @"$s12HelloLibrary13GetGInterface1tq_x_tAA0D0Rzr0_lF"(ptr noalias nocapture sret(%swift.opaque) %0, ptr noalias nocapture %1, ptr %T, ptr %U, ptr %T.GInterface) #0 {
entry:
  %T1 = alloca ptr, align 8
  %U2 = alloca ptr, align 8
  %t.debug = alloca ptr, align 8
  call void @llvm.memset.p0.i64(ptr align 8 %t.debug, i8 0, i64 8, i1 false)
  store ptr %T, ptr %T1, align 8
  store ptr %U, ptr %U2, align 8
  store ptr %1, ptr %t.debug, align 8
  %2 = getelementptr inbounds ptr, ptr %T.GInterface, i32 1
  %3 = load ptr, ptr %2, align 8, !invariant.load !65
  call swiftcc void %3(ptr noalias nocapture sret(%swift.opaque) %0, ptr %U, ptr noalias nocapture swiftself %1, ptr %T, ptr %T.GInterface)
  ret void
}

When calling methods with generics parameters, type metadata for each generic parameter is passed in declaration order between parameters and context registers. If the generic parameter is constrained by a protocol, there is another parameter for each conformance which is protocol witness table, following the type metadata. Generic types are opaque and an indirect result buffer is utilized, meaning that functions return void.

Text

The Text is a frozen struct that conforms to the View protocol. It has a two inlinable properties: storage and modifiers. Both types are frozen enums, where modifiers represent a Swift array. It can be projected as a C# struct:

[StructLayout(LayoutKind.Sequential, Size = 32)]
public struct Text : IView<Never>
{
    public Never Body => throw new InvalidOperationException("Text does not have a Body.");
    private Storage storage; // 2 bytes storage for String that is based on Foundation.Data
    private Flags flags; // 1 byte for discriminator + alignment
    private IntPtr modifiers; // Array of modifiers
}

The Text conforms to the View protocol without a concrete type for associated type. Swift has a frozen enum Never which has no values and can’t be constructed. To project this type in C#, the lowering algorithm could be updated to avoid lowering structs annotated with a custom attribute.

public struct Never
{
    private Never() {}
}

Additionally, structs may contain pointers to instances of classes, in which cases are considered non-POD and and value witness table should be consulted to copy or destroy them.

Button

The Button is a non-frozen struct that conforms to the View protocol. It should be projected as a C# class; layout and implementation can be determined at runtime.

Image

The Image is a frozen struct that displays an image and conforms to the View protocol. It can be projected as a C# struct:

[StructLayout(LayoutKind.Sequential, Size = 8)]
public struct Image : IView<Never>
{
    public Never Body => throw new InvalidOperationException("Image does not have a Body.");
    private AnyImageProviderBox provider;
}

UIHostingController

To integrate SwiftUI views and controls with a MAUI app, we can utilize UIHostingController class, which contain a SwiftUI view as its root. This class can be projected as a C# class that inherits from UIViewController:

public class UIHostingViewController : UIViewController,  : IView<View>
{
}

Since UIViewController is projected via the Objective-C runtime, it is important to understand the potential complications and design the object lifecycle carefully to avoid interference between these projections.

Future work

We can consider projecting additional SwiftUI APIs to extend functionality. For example, projecting gesture actions or immersive spaces can improve .NET applications' capabilities.

Tasks

List of tasks to support identified bindings:

kotlarmilos commented 1 month ago

This issue has been updated with a better-defined goal -- feel free to provide feedback on the general direction or identified projections.

jkotas commented 1 month ago

Associated type doesn't have direct mapping in C# and could be represented with generics:

The prevalent opinion in previous discussions has been that mapping of protocols and associated types to C# is going to be hard. Where is the catch?

Nit:

public struct Never
{
    private Never() {}
}

This fails to compile with "The parameterless struct constructor must be 'public'.".

kotlarmilos commented 1 month ago

The prevalent opinion in previous discussions has been that mapping of protocols and associated types to C# is going to be hard. Where is the catch?

My understanding is that some scenarios would be harder to implement than others. Let's start with simple scenarios first.

Generic methods are used in the CryptoKit and SwiftUI dev templates. For these, we need to pass an implicit metadata pointer for the concrete type that can be located and exposed as PInvoke during binding process.

Generic methods with protocol constraints require an implicit protocol witness table parameter for conformance. There are two ways to retrieve them: statically and dynamically. Protocol conformance records are emitted in __TEXT.__swift5_proto binary section and each record contains a pointer to protocol descriptor and protocol witness table. One option is to scan it and map them to a hash table during the binding process. Alternatively, they can be retrieved at runtime using swift_conformsToProtocol. Here is an interesting article on performance of protocol conformances scanning during app startup: https://medium.com/geekculture/the-surprising-cost-of-protocol-conformances-in-swift-dfa5db15ac0c.

Methods with protocols used as types - instead of passing C# interface pointers within the projection tooling, we need to pass a boxed existential container. We need to implement their construction within the projection tooling, probably at runtime, which will depend on protocol witness tables retrieval.

An addition to above-mentioned scenarios could be types with generics, which require passing concrete type metadata and calling swift_instantiateConcreteTypeFromMangledName to create a concrete type.

For protocols with associated types, I haven't yet encountered differences in public API and underlying calling convention. I may be missing something here. This is a brief overview of these scenarios, and I will initiate the design on protocols while keeping this issue open for further requirements collection.

jkotas commented 1 month ago

I agree that we should be always able to setup the right arguments and call the method. I am more worried about the projected API surface that we expose to user when protocols and generics are in the mix. Is the C# code that people end up writing against the projected API surface going to look reasonable?