Design a Powerful Plugin System by using PropertyWrapper and Mirror

The plugin system is a classical software design pattern. The most important parts of design in it are how to manage plugins and how to dispatching plugin messages. Managing plugins isn’t our topic today, I will introduce a particular design to dispatch or observe messages through Swift’s reflection mechanism and the PropertyWrapper syntax.

PropertyWrapper and Reflection

@PropertyWrapper is a Swift attributer that you can use it to mark a property, then you can execute some operations when the property is written or read. For detail you can see the blog I written early.

The Swift reflection mechanism is so weak compared to the runtime system in Objective-C, we rarely utilize it to do anything in our projects. Let’s see what the Swift reflection can support for us.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Person {
var name: String
var nameLength: Int { name.count }

init(name: String) {
self.name = name
}
}

let person = Person(name: "csl")
let mirror = Mirror(reflecting: person)
for child in mirror.children {
print("\(child.label ?? "none"): \(child.value)")
}

// name: csl

As you can see, using reflection can only access the object’s stored properties. Going a bit deeper, you will find the value of the child is a readonly property that means you can not assign a new value to the property, this is why the Swift reflection so useless.
But if the properties are of reference type, we can modify theirs member properties freely even though theirs reference pointers are immutable. How can we make sure that all properties we specified are of reference type and be used as same as normal properties? The property wrapper is the answer.

We declare a property wrapper class to wrap all properties we want to reflect in objects like below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@propertyWrapper
class ValueObserver<Value> {
private var rawValue: Value
var wrappedValue: Value {
get {
print("read value")
return rawValue
}
set {
print("write value")
rawValue = newValue
}
}

init(wrappedValue: Value) {
rawValue = wrappedValue
}
}

class Person {
@ValueObserver
var name: String = "'"
}

let person = Person()

let mirror = Mirror(reflecting: person)
for child in mirror.children {
print("\(child.label ?? "none"): \(child.value)")
if let name = child.value as? ValueObserver<String> {
name.wrappedValue = "csl"
}
}
print(person.name)

// _name: ValueObserver<Swift.String>
// write value
// csl

See the code above, you won’t be confused if understanding the property wrapper’s underlying implementation. The value are now of type ValueObserver, so we can now assign a new string "csl" to its wrappedValue property.

By using the PropertyWrapper, we have breakout the restriction of the reflection that forbids us modifying the reflection properties. Nowadays we can exploit the combination above to construct a powerful plugin system with implicit plugin registering, precise message dispatching and bridging to RxSwift seamlessly.

Plugin Register

Our plugin system applies the mediator design pattern, there are three key types: PluginContainer, Plugin and Message.
The Plugin is a protocol type that every classes or structures applying to it can register themselves as plugins into the plugin container.

1
2
3
4
5
public protocol Plugin {
init()
/// The plugin will be removed from the plugin container.
func pluginWillRemove()
}

You can regard the plugin container as the mediator and all registered plugins communicate with each other by sending messages to the plugin container. The plugin container will separate these messages according their types and resend them to corresponding plugins that subscript the specific plugin message types.

1
2
3
4
5
6
7
8
9
10
public final class PluginContainer {
private var registeredPlugins: [String: Plugin] = [:]
func register(_ plugin: Plugin, name: String) {
registeredPlugins[name] = plugin
}

func send<Message: PluginMessage>(message: Message) {
/// resend messages to plugins
}
}

There is a brief sample above showing the base structure of the plugin container to manage plugins. The most crucial question is how to observer messages and dispatch messages. Next, we need to understand the PluginMessage and the MessageObserver.

PluginMessage

The PluginMessage type is an empty protocol. Every type conforming to the PluginMessage protocol can be treated as plugin messages can be sent into the plugin container.

1
protocol PluginMessage {}

When designing a plugin system, the format of the plugin message is the first question we should consider carefully.

Normally, we can design the PluginMessage to a concrete struct type or class type, as shown below.

1
2
3
4
struct PluginMessage {
let type: String
let data: Any?
}

As shown above, the PluginMessage has two properties, type and data. The type string indicates the message’s kind. Of course, you can change the string type to enum type. The data is used to carry the message data.
There is a nasty weakness that you cannot get a structured message data when receiving a plugin message filtered by a specified type. We must forcefully cast the message data to the original real data type. The casting operation requires us to understand the original data type beforehand that we don’t know.

But if we don’t explicitly use a type property to distinguish the message and instead use the meta type of messages (one type of message refers to one kind of message), we can use custom properties in messages of different types to pass through type-checked data.

1
2
3
4
5
6
7
struct RefreshCourseInfo: PluginMessage {
let courseId: String
}


let pluginContainer = PluginContainer()
pluginContainer.send(message: RefreshCourseInfo(courseId: "1000"))

MessageObserver

The MessageObserver is a property wrapper for observing plugin messages of specified types.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@propertyWrapper
public class MessageObserver<Message: PluginMessage> {
public internal(set) var wrappedValue: Message? {
get {
assertionFailure("You shouldn't read the message observer's value. If you want to get the message, you can subscribe messages by accessing its projected value")
return nil
}
set {
guard let message = newValue else {
assertionFailure("Sending `nil` messages is illegal.")
return
}
relay.onNext(message)
}
}

private let relay = ReplaySubject<Message>()

public var projectedValue: Observable<Message> { relay.asObservable() }
}

If a plugin needs to subscribe to a kind of plugin message, we can create a message observer property typed with the plugin message ready to be subscribed in the plugin.

1
2
3
4
5
6
7
8
9
10
11
12
13
class TestPlugin: Plugin {
@MessageObserver
var refreshCourseMessage: RefreshCourseMessage?


init() {
$refreshCourseMessage
.bind(onNext: { message in
// TODO
})
.disposed(by: disposeBag)
}
}

As shown above, we can subscribe concisely a plugin message by declaring a instance property annotated with the MessageObserver property wrapper.

Message Dispatching

Finally, we should complete the send(message:) method in the PluginContainer in charge of distributing messages to plugins.

1
2
3
4
5
6
7
8
9
10
11
12
13
func send<Message: PluginMessage>(message: Message) {
for plugin in registeredPlugins.values {
let mirror = Mirror(reflecting: plugin)
// Finds all properties of the plugin object.
for member in mirror.children {
// Find message observers associating with the Message type.
if let messageObserver = member.value as? MessageObserver<Message> {
// Dispatches messages.
messageObserver.wrappedValue = message
}
}
}
}

First of all, we iterate all registered plugins and query their members with the Swift reflection. Then we filter the message observers observing the message type. Finally, we assign the plugin message to every observer’s wrappedValue to trigger the event sending in RxSwift.

Conclusion

The example above is just a toy code but clarifying the core concepts and tricks we needed.
Besides that, we can add message dispatching cache, message interceptor, message state and so on features.