Every now and then, I’m frustrated that SwiftUI is missing an obvious environment variable. I really expected it to have
@Environment(.keyboardShowing) var keyboardShowing
It doesn’t. But you can make your own. Simple example of how you use it here:
@main
struct KeyboardIndicatorApp: App {
var body: some Scene {
WindowGroup {
ContentView()
// Simple modifier injects the new environment variable
.addKeyboardVisibilityToEnvironment()
}
}
}
struct ContentView: View {
//You can then read it in any view
@Environment(\.keyboardShowing) var keyboardShowing
@FocusState private var isTextFieldFocused: Bool
@State private var text:String = ""
var body: some View {
TextField("Type Something...", text: $text)
.focused($isTextFieldFocused)
.safeAreaInset(edge: .trailing) {
//Button shows if keyboard is showing
Button {
isTextFieldFocused = false
} label: {
Image(systemName: "keyboard.chevron.compact.down")
}
.opacity(keyboardShowing ? 1 : 0)
}
.padding()
}
}
In this example, we show a ‘keyboard down’ button if the keyboard is showing. The button clears focus and hides the keyboard.
Note – this is a VERY contrived example. In this simple case, you could just use the @FocusState for the same job.
In my use case, the ‘hide keyboard’ button and the text field are in different parts of my view tree – so using the environment variable avoids having to create a more complex data-passing solution.
The implementation is based on this Stack Overflow answer, but with the addition of wrapping the publisher up into a view modifier, and using that to set an environment variable.
public extension View {
/// Sets an environment value for keyboardShowing
/// Access this in any child view with
/// @Environment(\.keyboardShowing) var keyboardShowing
/// You should add the modifier near the top of your view hierarchy (to RootView / ContentView or similar)
/// On MacOS, this is always false
func addKeyboardVisibilityToEnvironment() -> some View {
modifier(KeyboardVisibility())
}
}
private struct KeyboardShowingEnvironmentKey: EnvironmentKey {
static let defaultValue: Bool = false
}
public extension EnvironmentValues {
var keyboardShowing: Bool {
get { self[KeyboardShowingEnvironmentKey.self] }
set { self[KeyboardShowingEnvironmentKey.self] = newValue }
}
}
private struct KeyboardVisibility:ViewModifier {
#if os(macOS)
fileprivate func body(content: Content) -> some View {
content
.environment(\.keyboardShowing, false)
}
#else
@State var isKeyboardShowing:Bool = false
private var keyboardPublisher: AnyPublisher<Bool, Never> {
Publishers
.Merge(
NotificationCenter
.default
.publisher(for: UIResponder.keyboardWillShowNotification)
.map { _ in true },
NotificationCenter
.default
.publisher(for: UIResponder.keyboardDidHideNotification)
.map { _ in false })
.debounce(for: .seconds(0.1), scheduler: RunLoop.main)
.eraseToAnyPublisher()
}
fileprivate func body(content: Content) -> some View {
content
.environment(\.keyboardShowing, isKeyboardShowing)
.onReceive(keyboardPublisher) { value in
isKeyboardShowing = value
}
}
#endif
}
Creating our own environment value is very simple, and wrapping the whole thing up in a view modifier makes it really clean to add to a project.
I have added this to my HSSwiftUI package of useful SwiftUI extensions.
I’m building an app which needs to load Objective C files. If the user gives me access to the ‘.m’ file, I need to be able to open the ‘.h’ file.
You don’t get this ability without a bit of a dance. I didn’t find any clear tutorial or example, so I’m adding one now.
Firstly – define the related filetypes that you want to access by adding the file extension to document types in your info.plist Here, I have added h as an an extension type, and set NSIsRelatedItemType to true.
Now, you can access the file – but only by using a FilePresenter. And the file presenter _must_ be registered with NSFileCoordinator
class RelatedFilePresenter: NSObject, NSFilePresenter {
var primaryPresentedItemURL: URL?
var presentedItemURL: URL?
var presentedItemOperationQueue: OperationQueue = OperationQueue.main
enum Fail:Error {
case unableToConstrucURL
}
init(primaryItemURL: URL) {
self.primaryPresentedItemURL = primaryItemURL
}
func readDataFromRelated(fileExtension:String,
completion:(Result<Data, Error>)->Void) {
presentedItemURL = primaryPresentedItemURL?.deletingPathExtension().appendingPathExtension(fileExtension)
guard let url = presentedItemURL else {
completion(.failure(Fail.unableToConstrucURL))
return
}
let fc = NSFileCoordinator(filePresenter: self)
var error:NSError?
NSFileCoordinator.addFilePresenter(self)
fc.coordinate(readingItemAt: url,
options: .withoutChanges,
error: &error) { (url) in
do {
let data = try Data(contentsOf: url, options: .uncached)
completion(.success(data))
} catch {
completion(.failure(error))
}
}
if let error {
completion(.failure(error))
}
NSFileCoordinator.removeFilePresenter(self)
}
}
This class provides a method to read the data from a related file, but you could adapt it for any other operation.
You can then read the data with something like
let filePresenter = RelatedFilePresenter(primaryItemURL: mFileURL)
filePresenter.readDataFromRelated(fileExtension: "h") { result in
if case .success(let data) = result {
//do something with data
}
}
I’m going to have a shot at defining what @MainActor does, and what it does not guarantee.
Why?
I keep seeing tutorials and talks explaining @MainActor with something like the following quote
Isolation to the main actor is expressed with the MainActor attribute.
This attribute can be applied to a function or closure to indicate that the code must run on the main actor.
Then, we say that this code is isolated to the main actor.
The Swift compiler will guarantee that main-actor-isolated code will only be executed on the main thread, using the same mechanism that ensures mutually exclusive access to other actors.
– Eliminate data races using Swift Concurrency – WWDC 2022
This quote is simply untrue.
The Swift compiler absolutely 100% does not guarantee that main-actor-isolated code will only be executed on the main thread.
An untrue ‘promise’ is much more dangerous than no promise at all.
There is no real documentation on what @MainActor actually does. The best you can get is probably to look at the Swift Evolution proposals.
Two relevant ones are the proposal for actors (SE-0306) and global actors (SE-0316) I have no idea whether these are the only relevant ones, or whether they have been superseded.
There is no good way that I’m aware of to tell – other than having an intimate knowledge of all the proposals (which I do not).
I have been banging on for ages about how Apple should document this critical part of Swift, but they haven’t.
So I’m going to give it a go.
This will undoubtably be wrong in subtle and important ways!
Please let me know when you discover more edge cases, and I’ll update accordingly.
Current update: XCode 14.1, 21 Nov 2022
What is considered @MainActor
Starting with the obvious, any method or variable directly annotated with @MainActor
Next up – any method or variable in a class which is marked @MainActor – or in it’s extensions
Similarly – any method or variable in a class where one of it’s parents is marked @MainActor (This happens a lot when you’re using UIKit and AppKit)
What is considered @MainActor – Protocols…
Protocols are more subtle…
A method is treated as @MainActor if
Declaration – Both of these apply: a) The protocol declares the method as @MainActor b) The method is declared in the same block where the protocol conformance is declared
Usage: c) The object being called is ‘seen’ as an instance of the protocol – and not the original class
In the example below, ProtOne and ProtThree meet the ‘Declaration’ rules, so the relevant methods are always considered @MainActor
The more subtle case is illustrated with ProtTwo
The conformance is declared as an empty extension, The method is declared in the main block, so the ‘Declaration’ rules do not make it @MainActor
If you call two on a Foo object, it is treated as NOT @MainActor If you call two on a Foo object which is being ‘seen’ as a ProtTwo – then it IS treated as @MainActor
When is @MainActor ignored?
The swift compiler tries to figure out when @MainActor methods are being called – and tries to ensure that they are called on the main thread.
Because this is a compiler technology – it can only work at compile time.
Because it is extremely complex – it probably misses edge cases.
I’m not knowledgable enough to say for sure exactly why the following cases are actually ignored.
Some seem like dynamic code which is effectively running after the compiler checks…
For the examples, I use the two classes below.
Bar is the ‘pure swift’ version, OBar is annotated to allow ObjC interoperability.
I have separated Bar and OBar to illustrate that the issues don’t only happen when you use @ObjC interoperability, but all the examples work fine with OBar.
mainVar and mainFunc() are intended to be called on the main thread only.
In each case, I show code where mainVar or mainFunc are not called on the main thread.
(please let me know if you find more cases!)
– Keypaths
Keypaths completely ignore Swift Concurrency. This will call mainVar on a background thread
– Selectors
Calling a selector directly bypasses any Swift Concurrency Checks This calls mainVar on a background thread
– Objective C
Any time your @MainActor variable or method is called by ObjectiveC, concurrency rules are ignored
In this example, Swift calls MyObjC on the background thread – but the same problem exists if Swift calls an ObjC method, and _that_ method then moves to a background thread.
You can even pass pure swift code to ObjectiveC as a block – and it will be run. This one does at least generate a warning message
This situation is very common whenever you have a delegate pattern and ObjC code
This is a massively simplified example, but it is essentially what lots of Apple frameworks do. You call a class, and it calls you back via your delegate.
The Swift code here generates no warnings – but calls mainFunc on a background thread
– Any Library/Framework with a delegate, or a block callback (possibly)
This isn’t technically a separate case, but it is important enough to be worth emphasising.
Whenever you call a Framework or Library, you don’t know how they have implemented their code.
That means that any callback may be using Objective C, or might be ‘seeing’ your class as a Protocol, or could be using some other dynamic coding approach.
One of the first times I came across this in my own code was when using NSAlert in MacOS.
Clicking the ‘ok’ button sometimes called back on a background thread.
Apple coding is full of examples where this problem _might_ exist; CoreBluetooth, NSNotificationCentre, any third party Networking Framework just to take some super-obvious examples.
And the behaviour in those libraries/frameworks can change from version to version.
If you need to be sure – then you need to manually dispatch your code to the main queue
Conclusion
Swift Concurrency is GREAT.
I use it in all my code now, and it massively simplifies async work.
It is a highly impressive technical achievement. But it doesn’t magically fix everything – and the perception that it might is dangerous.
I think it is shameful that Apple has not provided documentation on exactly what it guarantees. This should be covered in the swift guide, with perhaps a tech note on the more subtle details.
Apple should not be making false claims about what it does in WWDC talks.
My hope is that they’ll provide proper documentation and I can deprecate this article…
PS: Top Tip…
Update – Add flag for warnings…
You can add a build flag to generate warnings when a @MainActor func/var is called on a background thread. It doesn’t stop @MainActor methods from being called in the background – but it at least generates a purple runtime warning for you to investigate.
The flag is -Xfrontend -enable-actor-data-race-checks
Add it to ‘Other Swift Flags’
Update – Binding doesn’t enforce @MainActor!
Rather surprisingly – Binding is an example of callbacks from code you don’t control which can ignore @MainActor. If you create your binding in an @MainActor context, then the setter/getter are considered @MainActor by the compiler – even though in fact they can be called from any thread.
Update – Words from a swift compiler engineer
I just had a really interesting chat with a swift compiler engineer during WWDC labs. Their take was that every time @MainActor code is called off the main thread – then that’s a simple straightforward bug. There is no limit to their ambition to enforce correctness here!
At this stage, I’m just riffing on different ways to ‘break’ swift concurrency.
I wondered if protocols would provide an opportunity, and indeed they do!
Take this scenario. We have a simple protocol – but the class implements it with an @MainActor annotation.
If you do this all within the main class definition, then you get a warning
However, you can declare conformance in an extension and make it @MainActor No warning here
Or perhaps more confusingly – you can declare your whole class @MainActor, then just conform ‘normally’ in the extension
– in this last case, I wasn’t sure whether it would actually be treated as an @MainActor variable – but I tested, and indeed it is.
So – we have three ways of conforming to a protocol which _doesn’t_ require @MainActor – but in each case, we have required @MainActor in our implementation
Swift Concurrency will use the MainThread for an instance which it recognises as Bar, and won’t bother for an instance it recognises as Thing
I can see the logic here – but once again, @MainActor isn’t guaranteeing anything. The actual call depends subtly on what kind of thing the compiler recognises.
So – we have three subtly different ways of generating exactly the same code. One of them generates a compile-time warning, the other two do not.
I generally prefer declaring conformance to a protocol in an extension – I had no idea that meant I would get different compile-time warnings.
Sometimes the method is called on Main – Sometimes not!
@MainActor
func makeBar() -> Bar {
return Bar()
}
func breakWithProtocol() {
Task {
var bar:Bar = await makeBar()
//The compiler recognises a Bar and insists we call asynchronously
//It will call on the main thread
_ = await bar.thing
}
Task {
let thing:Thing = await makeBar()
//The compiler just sees a Thing here - so it calls directly on a background thread
_ = thing.thing
}
}
I just wish Apple would clearly document what it actually guarantees
Generally, sites (including Apple) explain that marking a function with @MainActor guarantees that it will always run on main.
By adding the new @MainActor annotation to Photos, the compiler will guarantee that the properties and methods on Photos are only ever accessed from the main actor.
Discover Concurrency in SwiftUI – Apple
This simply isn’t true.
Here are five simple functions that cause a @MainActor function to run on a background thread…
class OnMain:NSObject {
@MainActor
@objc
func noCanFail() {
guard Thread.isMainThread else {
fatalError("not on main")
}
print("Did Not Fail!")
}
//Just call the selector directly. Swift doesn't care
func breakWithSelector() {
Task {
self.perform(#selector(self.noCanFail))
}
}
// Get some Objective C code to call the 'protected' function
// The objective C code is simply
// [[OnMain new] noCanFail];
func breakByCallingObjC() {
Task {
Trouble.cause()
}
}
//NSNotification centre calls you back on whatever thread the notification was posted
//It doesn't care about Swift Concurrency
func breakByNotification() {
let notification = Notification.Name("Cheeky")
let center = NotificationCenter.default
center.addObserver(self,
selector: #selector(self.noCanFail),
name: notification,
object: nil)
Task {
center.post(name: notification, object: nil)
}
}
//Surprisingly, even with block syntax, you get the fatal error
//At least there is a warning in this one
func breakByNotification2() {
let notification2 = Notification.Name("Cheeky2")
let queue = OperationQueue()
let center = NotificationCenter.default
center.addObserver(forName: notification2,
object: nil,
queue: queue) { _ in
self.noCanFail()
}
//No need to post from a task this time!
center.post(name: notification2, object: nil)
}
//Objective C will just run the block for us by calling
//block();
//At least there is a warning in this one
func breakWithBlock() {
Task {
Trouble.run {
self.noCanFail()
}
}
}
func breakFoo() {
let foo = Foo()
let library = MyLibrary(delegate: foo)
library.doWork()
}
}
Two of these functions generate a warning in XCode 14.1 -three don’t. They all trigger fatal errors when they end up calling noCanFail on a background thread.
This isn’t by any means an exclusive list.
The most obvious point where bugs like this can bite you is that any time you have a callback function from a system framework (such as bluetooth discovery, or even an Alert), or from a third party framework like a networking library – then it _could_ call back on any thread.
You might get lucky – but in this scenario, @MainActor does precisely nothing.
To my mind, the great sin here is not that Swift Concurrency has edge cases.
The great sin is that Swift Concurrency is presented as if it ‘just works’ – when in fact there are important caveats that you need to manage.
@MainActor is barely documented by Apple. There is a single oblique reference to it in ‘the Swift Programming Language’
It is a core part of the language, it’s ridiculous that Apple doesn’t have clear documentation on what it actually does.
The Rules (?)
My understanding is something like the following:
@MainActor works as long as you…
Are calling from your own code
Are not using selectors
You only use Swift
You don’t use any other form of concurrency (like OperationQueue)
NB – I am not claiming that these rules are 100% correct. There are probably edge cases even within these rules.
Apple should document what the real rules are.
Rule #1 is trickier than you might think…
It is up to you to ensure that any code which is protected by @MainActor is only called directly from your own code.
If you have a callback from a library/framework, and that calls a function, which calls a function, which eventually calls your protected function – then that’s a problem.
This is true even if you’re providing a callback to an Apple framework like SKStoreKit
It’s _really_ easy to miss something like this.
Imagine the following common pattern
class Foo:NSViewController {
}
//perhaps conforming to some delegate protocol...
extension Foo:MyLibraryDelegate {
func someCallback() {
OnMain().noCanFail()
}
}
You have a UIViewController or an NSViewController.
That does calls a framework/library (perhaps SKStoreKit or a networking library)
The delegate is able to call noCanFail() in the extension here because the whole class has inherited @MainActor from NSViewController
The library is something trivial that ends up calling the delegate off the main thread
Once again – a fatal error. No warnings in the compiler (at least not with default XCode settings)
Swift Concurrency is great. I’ll continue to use it.
Carefully.
Update – Add flag for warnings…
Thomas Goyne pointed out that that you can add a build flag to generate warnings in these cases. It doesn’t stop @MainActor methods from being called in the background – but it at least generates a purple runtime warning for you to investigate.
The flag is -Xfrontend -enable-actor-data-race-checks
Add it to ‘Other Swift Flags’
I’ll be adding this to all my projects.
Update – Keypaths…
Probably not surprising at this point. Keypaths seem to completely ignore Swift Concurrency
No Objective C involved here at all
@MainActor
var doNotFail:Int {
guard Thread.isMainThread else {
print("Failed")
return 0
}
return 0
}
func breakWithKeypath() {
Task {
var array = [OnMain()]
var sorted = array.map(\.doNotFail)
}
}
doNotFail is merrily called on a background thread…
tldr – marking methods with @MainActor does not guarantee that they run on the main thread.
Swift has a magical new structured concurrency model.
‘using Swift’s language-level support for concurrency in code that needs to be concurrent means Swift can help you catch problems at compile time’
-the swift programming language
One of the new features is @MainActor. You can use this to annotate functions, classes, properties, etc.
this proposal introduces MainActor as a global actor describing the main thread. It can be used to require that certain functions only execute on the main thread
link
So – what does @MainActor do? I hoped to find some documentation, but other than the evolution proposal, I haven’t found any.
There are some tutorials out there which say things like:
all that we really need to know is that this new, built-in actor implementation ensures that all work that’s being run on it is always performed on the main queue.
My expectation is that calling the function printDate or the property mainDate will always run the code on the main thread. As such, they’ll always print “main: true” in their debug statements.
I created an async function which calls these
func doWork() async {
let _ = await Background().go()
print("returned from Background - now running off main thread")
print("calling mainDate in doWork")
self.storedDate = self.mainDate //sometimes not main thread
printDate("in doWork") //sometimes not main thread
}
The first call in the async function is to a background actor.
//Actor that isn't main - it does not run on the main thread
actor Background {
func go() -> Date {
print("Background go - main: \(Thread.isMainThread)")
return Date()
}
}
The effect of the background actor is simply to return from the await on a background (not main) thread.
This ensures that I’m not simply calling mainDate and printDate on the main thread by default.
I call doWork from a task responding to a button click
For good measure – I mark the task as @MainActor. My expectation was the following
in doWork(), it would return off the main thread after Background().go()
@MainActor annotation would ensure that calling self.mainDate would happen on the main thread – or there would be a compiler error
@MainActor annotation would ensure that calling self.storedDate would happen on the main thread – or there would be a compiler error
#2 and #3 are false. I get the following output when I run the code
returned from Background - now running off main thread
calling mainDate in doWork
@MainActor var - main: false
@MainActor func - in doWork - main: false
Note the last two lines print main:false – @MainActor isn’t keeping me on the main thread here…
Bizarrely – If include a print statement in my task, then everything _does_ run on the main thread!!!
@IBAction func doWorkInAsyncFunctionWithPrint(_ sender: Any) {
Task { @MainActor in
//Adding this print statement, magically makes everything in doWork run on the main thread!!!
print("Srsly?")
await doWork()
}
}
gives the following “correct” output
Srsly?
Background go - main: false
returned from Background - now running off main thread
calling mainDate in doWork
@MainActor var - main: true
@MainActor func - in doWork - main: true
Similarly – if I call identical functions, but within a Task (and not even one annotated as @MainActor), then I get the “correct” results
@IBAction func doInTask(_ sender: Any) {
Task {
let _ = await Background().go()
print("returned from Background - now running off main thread")
print("calling mainDate doInTask")
self.storedDate = self.mainDate //main thread
printDate("in doInTask") // main thread
}
}
gives the following…
Background go - main: false
returned from Background - now running off main thread
calling mainDate doInTask
@MainActor var - main: true
@MainActor func - in doInTask - main: true
Is this even wrong?
Swift isn’t behaving as I would expect it to. That’s hardly damning!
However:
I can’t find any formal documentation to compare against.
I can’t look up what the guarantee (if any) is that Swift provides when you annotate something with @MainActor
I have mostly learned from the brilliant resources provided by sites like swiftbysundell
all that we really need to know is that this new, built-in actor implementation ensures that all work that’s being run on it is always performed on the main queue.
The magic of @MainActor is that it automatically forces methods or whole types to run on the main actor, a lot of the time without any further work from us.
-hacking with swift
It seems that I’m not alone in what I expected @MainActor to do. Perhaps this is a bug. Perhaps it is expected behaviour. In the absence of clear documentation showing what @MainActor should to, I can’t tell.
Either way – marking methods with @MainActor does not ensure that they run on the main thread.
Update: Warnings Available…
If you’re writing Swift concurrency code, add these compiler flags:
returned from Background - now running off main thread
calling mainDate in doWork
warning: data race detected: @MainActor function at MainActorExample/ViewController.swift:26 was not called on the main thread
2022-01-17 14:43:40.786070+0000 MainActorExample[4407:7439502] warning: data race detected: @MainActor function at MainActorExample/ViewController.swift:26 was not called on the main thread
@MainActor var - main: false
warning: data race detected: @MainActor function at MainActorExample/ViewController.swift:31 was not called on the main thread
2022-01-17 14:43:40.811866+0000 MainActorExample[4407:7439502] warning: data race detected: @MainActor function at MainActorExample/ViewController.swift:31 was not called on the main thread
@MainActor func - in doWork - main: false
This does show runtime a bunch of warnings.
‘cannot use parameter ‘self’ with a non-sendable type ‘ViewController’ from concurrently-executed code’ and
‘cannot call function returning non-sendable type ‘Date’ across actors’
I’ll experiment some more…
Update 2: Models get errors!
Moving the exact same code out of the NSViewController and into a separate class (that inherits from nothing) causes the expected compiler warnings to kick in.
Interestingly, when this code is moved to a separate model, calling self.mainDate without async causes an error. However in the original ViewController version – adding an async (async self.mainDate) generates a warning
‘no ‘async’ operations occur within ‘await’ expression’
I included the email I received from their representative about the price I would have to pay
You should also know that there are fees associated with becoming licensed. Specifically, there is a $25,000 one-time initial fee and a recurring $5,000 annual maintenance fee. There is also a per-unit royalty that has a tiered structure, due quarterly, based on annual total usage, as follows:
0-100,000 downloads at $0.99 per download
100,001-1,000,000 downloads at $0.67 per download
1,000,001+ downloads at $0.45 per download
I was interested to get a call from a lawyer working for Adobe recently. They were being sued by Dolby for something related to these patents.
Bizarrely, Dolby were arguing that their pricing levels were so super secret that they couldn’t even disclose them in court.
Adobe found my old post and argued that if they were published on the web – they couldn’t be that secret.
I don’t have any details about the case, but I know they settled and it went away. Hopefully my pricing info made the difference…
In the last week or so, both Apple and Google informed me that one of my apps was in a small way breaching store guidelines.
I’ll paraphrase both messages here:
One of your apps hasn’t been updated in years. It doesn’t meet current guidelines. If you don’t update it – we’ll remove it in 30 days.
Apple
and
One of your apps breaks the metadata guidelines. It has a quote in the app description.
We have removed it from the store.
Google
Both complaints are probably valid. The iOS app hasn’t been updated since 2015 and hasn’t been optimised for the latest devices. I’ll update it this month.
The Android app did have the following quote in the store description
VLC Remote is the latest Android app to earn my favor, and it’s a beauty
Androidnica
The quote has been there for at least 5 years. I’m not entirely sure whether it should count as a ‘user testimonial’ (banned) or a ‘third party review’ (I think those are ok). But either way – I’m happy to remove it.
Removing my app from the store just seems like a Massive Overreaction when they could simply have emailed me to request a change.
Use GSuite Routing to dynamically handle different iTunes Connect test emails.
iTunes connect testing is a pain. One of the pain points is that you have to create a bunch of iTunes Connect accounts when you want to test your purchase flows. Each of those needs a separate email address.
You used to be able to do this with the magic
rob+test1@hobbyistsoftware.com
rob+test2@hobbyistsoftware.com
And all your emails would be routed through to rob@hobbyistsoftware.com (this works for gmail, and many other email providers)
Sadly Apple disabled this capability some time in 2018 (?) – so now you need a new valid email address for every iTunes Connect sandbox user
GSuite Routing provides a neat way to restore this functionality
Open your GSuite Management Console
Click through to Apps > G Suite > Gmail
Click on ‘Default Routing’
Click ‘Add Setting’ and add something like the following
(note – the regexp is rob_.*@hobbyistsoftware.com)
This redirects all email of the format rob_something@hobbyistsoftware.com to rob@hobbyistsoftware.com