iosswiftobjective-cobjective-c-blocks

Calling block from Objective C collection in Swift


I have a Swift object that takes a dictionary of blocks (keyed by Strings), stores it and runs block under given key later at some point depending on external circumstances (think different behaviours depending on the backend response):

@objc func register(behaviors: [String: @convention(block) () -> Void] {
  // ...
}

It's used in a mixed-language project, so it needs to be accessible from both Swift and Objective-C. That's why there's @convention(block), otherwise compiler would complain about not being able to represent this function in Objective-C.

It works fine in Swift. But when I try to invoke it from Objective-C like that:

[behaviorManager register:@{
  @"default": ^{
    // ...
  }
}];

The code crashes and I get following error:

Could not cast value of type '__NSGlobalBlock__' (0x...) to '@convention(block) () -> ()' (0x...).

Why is that, what's going on? I thought @convention(block) is to specifically tell the compiler that Objective C blocks are going to be passed, and that's exactly what gets passed to the function in the call.


Solution

  • That's why there's @convention(block), otherwise compiler would complain about not being able to represent this function in Objective-C

    For the sake of consistency: commonly you use @convention attribute the other way around - when there is an interface which takes a C-pointer (and implemented in C) or an Objective-C block (and implemented in Objective-C), and you pass a Swift closure with a corresponding @convention as an argument instead (so the compiler actually can generate appropriate memory layout out of the Swift closure for the C/Objective-C implementation). So it should work perfectly fine if it's Objective-C side where the Swift-created closures are called like blocks:

    @interface TDWObject : NSObject
    
    - (void)passArguments:(NSDictionary<NSString *, void(^)()> *)params;
    
    @end
    

    If the class is exposed to Swift the compiler then generates corresponding signature that takes a dictionary of @convention(block) values:

    func passArguments(_ params: [String : @convention(block) () -> Void])
    

    This, however, doesn't cancel the fact that closures with @convention attribute should still work in Swift, but the things get complicated when it comes to collections, and I assume it has something with value-type vs reference-type optimisation of Swift collections. To get it round, I'd propose to make it apparent that this collection holds a reference type, by promoting it to the [String: AnyObject] and casting later on to a corresponding block type:

    @objc func takeClosures(_ closures: [String: AnyObject]) {
        guard let block = closures["One"] else {
            return // the block is missing
        }
        let closure = unsafeBitCast(block, to: ObjCBlock.self)
        closure()
    }
    

    Alternatively, you may want to wrap your blocks inside of an Objective-C object, so Swift is well aware of that it's a reference type:

    typedef void(^Block)();
    
    @interface TDWBlockWrapper: NSObject
    
    @property(nonatomic, readonly) Block block;
    
    @end
    
    @interface TDWBlockWrapper ()
    
    - (instancetype)initWithBlock:(Block)block;
    
    @end
    
    @implementation TDWBlockWrapper
    
    - (instancetype)initWithBlock:(Block)block {
        if (self = [super init]) {
            _block = block;
        }
        return self;
    }
    
    @end
    

    Then for Swift it will work as simple as that:

    @objc func takeBlockWrappers(_ wrappers: [String: TDWBlockWrapper]) {
        guard let wrapper = wrappers["One"] else {
            return // the block is missing
        }
        wrapper.block()
    }