Building dynamic UI with nibs and storyboards (Part 2)
In the first part of this series, we saw building dynamic UI leveraging loose coupling of IB-built UIs. If you have not read it, go through it first. This article will build up on the major limitations it had. The main one cannot add or update controller logic code or event triggers/actions. This article will leverage JavaScript for dynamic logic and our dynamic UI from Interface Builder.
We will dive deeper into Objective-C runtime and advanced features like dynamic method resolution and message invocations while using Swift. We will also bridge types between JavaScript and Swift, establishing two-way communication between these runtimes.
Why JavaScript?
The goal we are trying to accomplish here is to enable dynamic logic populated by the remote server without updating the app. Due to iOS security limitations, it is not possible to add and load the random dynamic library after the app’s published to the store:
To protect the system and other apps from loading third-party code inside their address space, the system performs a code signature validation of all the dynamic libraries that a process links against at launch time.
This prevents us from using compiled languages like Swift. The workaround is to use interpreted languages like JavaScript and Python. Since iOS already has a JavaScript interpreter bundled with the system browser and with the help of JavaScriptCore, we can establish Swift-JavaScript communication easily. We are going to use JavaScript for this example.
Bridging Swift-JavaScript
JavaScriptCore framework allows us to establish a two-way communication bridge between JavaScript and Swift, sharing data, invoking methods, and passing callbacks. We must create a JSContext to execute JavaScript code to achieve this communication. Since we want to allow UI modification from this context, we must create the JSVirtualMachine to host this context from the main thread. This will allow JSVirtualMachine to inherit the main thread’s run-loop for code execution.
We are passing the Swift controller instance to JavaScript to allow direct modification. Since we need to keep string reference of this context to ensure the lifetime is the same as the controller, the controller instance is passed as a weak reference wrapped inside a closure to avoid retaining the cycle.
Passing the object to JavaScript doesn’t allow access to all the instance methods or properties. We have to manually export them by confirming them to JSExport. We must create an export protocol that inherits JSExport for each class and add the desired properties and methods to export. For this example, we will expose the IBOutlets of the controller and text property for UILabel.
Now, we are ready to consume JavaScript code from the server and execute it in created context. The file will be provided with the same approach as the nib files used by the server in the previous article. We can extract the content of the downloaded file and execute it in our context.
Now that we have the JS bridge set up, in the next sections, we will discuss how to expose all the IBOutlets dynamically and forward action events to JavaScript.
Dynamic IBOutlets
IBOutlets are used to keep a reference of UI objects to modify their behaviour later, i.e., changing text, colour, etc. Typically, one IBOutlet can be attached to one property, or multiple IBOutlets can be attached as a group to an array property. In this section, we will store outlets as key-value pairs, with the key being the property name defined in xib or storyboard.
Adding @IBOutlet to the property field achieves the following things:
- Expose the field name to IB to give feedback in Xcode on whether the outlet is attached.
- In the case of Swift, the property is exposed to Obj-C similar to @objc attribute, and makes the property accessible via key-value coding.
During the initialization of the controller from nib, all such outlets are assigned with key-value coding using setValue(_:forKey:), where the UI object is passed as value and the outlet field name as the key. In our case, since the field doesn’t exist, setValue(_:forUndefinedKey:) is invoked, which by default raises an exception.
Terminating app due to uncaught exception ‘NSUnknownKeyException’, reason: ‘[<{controllerClass} {address}> setValue:forUndefinedKey:]: this class is not key value coding-compliant for the key {outletName}.’
where controllerClass is the controller class name, address represents memory address, and, most importantly, outletName represents the outlet that is not attached. This exception is being raised since we don’t have any property with the outlet name exposed.
To avoid exceptions and accomplish our task, we can override this method to store the objects in our own dictionary. Similarly, we have to override value(forUndefinedKey:) to allow getting the properties from our custom dictionary.
Now that we have exposed outlets to JavaScript, we can set exported properties from JavaScript code. Let’s see the label’s text live update after attaching Safari Web Inspector to our JSContext and setting the label’s text.
Dynamic IBActions
Similar to IBOutlets, IBActions are just Obj-C methods that can be invoked via messaging. When the associated event is triggered, the message is passed with the attributes specified. The absence of methods causes exceptions on the event trigger.
Terminating app due to uncaught exception ‘NSInvalidArgumentException’, reason: ‘-[{controllerClass} {actionSelector}]: unrecognized selector sent to instance {address}’
To resolve this for our use case, we can forward the method invocation to the JavaScript object that contains the implementation. There are multiple ways of adding message forwarding in Obj-C:
- Dynamic method resolution: After getting a call back in resolveInstanceMethod(_:) for an unimplemented method class_addMethod(_:_:_:_:) can be used to provide a dynamic implementation.
- Message Forwarding: Messages related to unimplemented methods can be forwarded to another object that handles the message. Forwarding has two approaches:
– Using forwardInvocation: which is only available in Obj-C.
– Using the less expensive forwardingTarget(for:) available in Swift.
In this example, we will use the first approach, supporting up to two arguments. Since the direct conversion of Swift variadic methods to C variadic methods is impossible, we must implement multiple methods based on the arguments count approach. The advantage of resolveInstanceMethod(_:) is it is invoked once for a selector and class type combination. Subsequent message passing will pick up the selector implemented in the first instance.
Note that resolveInstanceMethod(_:) will also be invoked for dynamic IBOutlets, but since we already have a specific implementation in place for them, we can only provide an implementation for IBActions. In the snippet provided above, we are detecting selectors beginning with action as our dynamic IBActions.
With the client implementation complete, we can create our JavaScript file that initializes our dynamic page with some data, implements dynamic IBActions that handle the callback from our dynamic view, and uses dynamic IBOutlets to update data in the dynamic page.
Let’s see our work in action
Our sample app with a simple counter is now ready and completely functional, with all the user actions being reflected on UI timely and properly. The beauty of this is our dynamic UI is completely native, and our controller logic is completely dynamic. Finally, we have achieved a completely server-driven UI and server-driven state and user-action management.
Now we can modify our controller logic and page UI by modifying their corresponding js and xib file, respectively. After rerunning our server to publish the latest changes, we can refresh our implemented dynamic page (by trying to land on the page again) to see our latest UI changes and user interaction effect without redeploying or restarting our iOS client app.
Conclusion
Although this example built is pretty simple, this can be extended to build a fully functional app.
- Using caching for better page load performance
- Using versioning to tie nib/storyboard files with their js counterpart and avoid runtime exceptions possible due to IBActions mismatch.
- Using native code that can be populated with data from the JavaScript layer to perform complex UI modifications, i.e., animations, etc.
This, in principle, works like a website while behaving like a native app. You can build a complete server-driven app solution with a completely native UI and customisable state, user-action, and routing management.
The one side effect of using this approach is that since view-controllers are being passed to JavaScript, JavaScript memory management rules also apply to them. The controller will be deallocated once JavaScript’s GC releases its reference. Unlike Swift’s ARC uses a different GC algorithm which releases memory periodically, and hence there will be some delay in deinit invocation after popping back from a controller.
Let me know if you are using any other approaches for your server-driven app. Also, let me know if you see any drawbacks to this approach. The complete implementation can be found at:
GitHub – SwiftyLab/DynamicNib: Dynamically load nibs in ios app
The following are the resources used for this article. You can delve deeper into these for more details:
- App code signing process in iOS and iPadOS
- About Key-Value Coding
- Messaging
- Dynamic Method Resolution
- JavaScriptCore | Apple Developer Documentation
- Memory management – JavaScript | MDN
Combining Interface Builder With JavaScript for Server-driven Apps was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.