Using Go for Mobile Apps

I have been developing Digital Carrot with Go Mobile for the last year and it has been a largely positive experience. This article is meant to serve as a repository of everything I’ve learned throughout the process. Why did I choose Go Mobile? The short answer is that I really like Go and wanted to use it. The longer answer is that is some combination of the following: Digital Carrot is a cross platform application, and I wanted something that I could easily compile and run anywhere. Go has a strong library of system features that I wanted to use to make Digital Carrot customizable and pluggable. Chief among these are Expr (for building expressions) and Goja (for creating JavaScript plugins). How am I using Go Mobile? Go mobile is used for all the business logic for Digital Carrot. While I would have loved to use Fyne for the UI, I gave it a try and decided it just isn’t quite mature enough. Instead, I opted to use Flutter with a Go backend Here is more or less what it looks like: Flutter calls into Switf/Kotlin using platform channels API calls are binary encoded protobuf messages Native code forwards the raw binary to Go Go decodes the protobuf messages and responds Go can also call native code via interfaces when it needs to interact with native APIs flowchart TD native["Native Code (Swift, Kotlin)"] ui["Flutter UI"] go["Go Mobile Backend"] ui -- Protobuf binary API calls --> native native <-- Protobuf forwarded to Go --> go go -- Calls to native APIs (screen time, health) --> native Flutter to Go Communication As mentioned above, Flutter communicates with Go using Protobuf messages. Protobuf is a MUST here as it allows me to work with nice structs/classes in Go and Dart without having to do a lot of manual marshalling and unmarshalling of JSON objects. I can define my messages in Protobuf and automatically get nice objects in Dart and Go to work with. Communication between Flutter, platform code and Go can only be done with basic data types (strings, binary, booleans, etc) so Protobuf messages are perfect for this use case. Defining new platform channels is also a huge chore in Flutter since they need to be defined in three places (Flutter, platform and Go), so I ended up going with a single function that handles all API calls. This function passes a message with a large oneof block to define the actual API call. It looks something like this: 1 2 3 4 5 6 message CarrotAPI { oneof api { Function1API function1 = 10 ; Function2API function2 = 11 ; } } Each function call in the oneof block looks something like this: 1 2 3 4 5 6 7 message Function1API { message Request {} message Response {} Request request = 1 ; Response response = 2 ; } Flutter populates the request part of the message, throws it in the CarrotAPI object and sends it to Go. Go can tell what function is being called through a switch statement on CarrotAPI.api and populate the correct response. This is a little clunky, but it’s much better than creating new platform channels for every function and saves a lot of work that would otherwise need to be duplicated across Swift and Kotlin. I won’t go into specifics on how to implement this. There are other articles that do a better job of explaining how this works. Go to Swift/Kotlin Communication This is one area that I struggled with for a long time because this is not documented well in Go. Essentially, to send messages from Go to platform code you need to create a Go interface that is then implemented on the platform side and passed into Go when the Go code is invoked. Here’s an example: We create a Go interface like this: 1 2 3 4 5 type IosMethods interface { // Screentime SetShields ([] byte ) bool HasScreentimePermissions () bool } Go Mobile generates an objective C or Kotlin interface like this: 1 2 3 4 5 6 @interface MobileIosMethods : NSObject { } @property(strong, readonly) _Nonnull id ref; - (BOOL)hasScreentimePermissions; - (BOOL)setShields:(NSData* Nullable)p0; @end Which we can implement in Swift or Kotlin and pass back into Go: 1 2 3 4 5 6 7 8 9 class GoScreentime : NSObject , MobileIosMethodsProtocol { public func setShields ( p0 : Data ?) -> Bool { return setShieldsFromJSONBytes ( p0 ) } public func hasScreentimePermissions () -> Bool { return AuthorizationCenter . shared . authorizationStatus == . approved } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @ main @objc class AppDelegate : FlutterAppDelegate { override func application ( application : UIApplication , didFinishLaunchingWithOptions launchOptions : [ UIApplication . LaunchOptionsKey : Any ]? ) -> Bool { [...] self . carrotApi = DigitalCarrot . MobileNewAppleMobileAPI ( GoScreentime () ) [...] } } Once again, these interfaces only support primitive data types. This is another area where Protobuf could be helpful, but I didn’t end up using it since there aren’t many calls that need to be made from Go to the platform code. More information about this can be found in this excellent article . How is all of this working out? So far I really like this stack. Working with Go is such a pleasure, so that alone is well worth it to me. Beyond my personal weirdness, this approach has some serious pros and cons. The Good Seamless integration with the server The Digital Carrot sync server is also implemented in go. This makes testing sync a breeze because I can just import the client code directly into my server sync tests. I don’t have to build any complicated test harnesses with docker to spin up client and server applications running on different stacks. I can literally just go test it on my Mac and the tests run in under 20 seconds. Strong business/presentation layer boundary This is mostly self explanatory. It’s difficult to mix business logic with presentation logic because they are written in different languages. The Flutter component of the app just handles the UI (and some minor platform specific stuff such as requesting permissions) and the Go component handles all of the business logic (communicating with the server, saving data, validation, etc). One real benefit is that testing the API contract is trivially easy. All of the business logic is tested in Go, which once again, means I don’t have to spin up any complicated UI components to test the app. I can write integration tests in Go and just run them with go test . One unexpected benefit here has been replay testing. I can test the app manually in the UI, record the API calls that the UI makes and then run those API calls back programmatically. Some UI tests are still required, but they can be very minimal since we can assume that the app’s business logic is rock solid. This separation of concerns also means that it is really easy to replace the UI with something else in the future. If I wanted a more native experience, I could implement the iOS app in Swift UI and the Android app in Kotlin. This is really nice to have in my back pocket given Google’s propensity for killing projects. I can be reasonably sure that Go will never disappear, but who can say what will happen in the wacky world of UI. Large collection of libraries to pull from This architecture means that I get access to the full suite of tools available to Go and Dart. The Dart ecosystem provides just about any utility I might want for interfacing with platform specific permissions and Go has a rich assortment of system programming and networking tools that I can pull from. The business logic can run over any protocol Putting all of my communication on a network capable interface such as protocol means that the backend can run just about anywhere. This has been hugely advantageous on Windows and Mac. On these platforms the backend runs as a daemon in the background and the UI connects to it via gRPC over sockets or pipes. Digital Carrot needs to continuously run in the background to be able to block programs and websites, so being able to just run the Go daemon on it’s own is great since there is no need to load all of the UI cruft into memory. As a…

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论