Giphy / giphy-ios-sdk

Home of the GIPHY SDK iOS example app, along with iOS SDK documentation, issue tracking, & release notes.
https://developers.giphy.com/
Mozilla Public License 2.0
116 stars 52 forks source link

[CRASH] Invalid batch updates detected #225

Closed Nico3652 closed 1 week ago

Nico3652 commented 1 year ago

During 6 months of testing I had no error / crashes using this SDK but recently (maybe with new Xcode version) I'm getting a crash when I'm initializing the main grid for recents with exactly the same code than previous months :

func initController(completion: ((Bool?) -> Void?)) {
        if(gridController == nil) {
            gridController = GiphyGridController()
            gridController!.delegate = self

            // space between cells
            gridController!.cellPadding = 4.0

            // the scroll direction of the grid
            gridController!.direction = .vertical

            // the number of "tracks" is the span count. it represents num columns for vertical grids & num rows for horizontal grids
            gridController!.numberOfTracks = 3

            // hide the checkered background for stickers if you'd like (true by default)
            //gridController!.showCheckeredBackground = false
            gridController!.view.backgroundColor = .clear

            gridController!.theme = GiphyGridTheme()

            // by default, the waterfall layout sizes cells according to the aspect ratio of the media
            // the fixedSizeCells setting makes it so each cell is square
            // this setting only applies to Stickers (not GIFs)
            gridController!.fixedSizeCells = true

            if(GPHRecents.count > 0) {
                gridController!.content = GPHContent.recents
            }
            else {
                gridController!.content = GPHContent.trending(mediaType: .sticker)
            }

            mainView!.addSubview(gridController!.view)

            gridController!.view.translatesAutoresizingMaskIntoConstraints = false

            gridController!.view.leftAnchor.constraint(equalTo: mainView!.safeLeftAnchor).isActive = true
            gridController!.view.rightAnchor.constraint(equalTo: mainView!.safeRightAnchor).isActive = true
            gridController!.view.topAnchor.constraint(equalTo: mainView!.safeTopAnchor).isActive = true
            gridController!.view.bottomAnchor.constraint(equalTo: mainView!.safeBottomAnchor).isActive = true

            gridController!.update()
            completion(true)
        }
        else {
            completion(false)
        }
    }

And then I'm getting this error :

*** Assertion failure in -[UICollectionView _Bug_Detected_In_Client_Of_UICollectionView_Invalid_Batch_Updates:], UICollectionView.m:10064
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid batch updates detected: the number of sections and/or items returned by the data source before and/or after performing the batch updates are inconsistent with the updates.
Data source before updates = { 1 section with item counts: [50] }
Data source after updates = { 1 section with item counts: [50] }
Updates = [
    Insert item (0 - 0),
    Insert item (0 - 1),
    Insert item (0 - 2),
    Insert item (0 - 3),
    Insert item (0 - 4),
    Insert item (0 - 5),
    Insert item (0 - 6),
    Insert item (0 - 7),
    Insert item (0 - 8),
    Insert item (0 - 9),
    Insert item (0 - 10),
    Insert item (0 - 11),
    Insert item (0 - 12),
    Insert item (0 - 13),
    Insert item (0 - 14),
    Insert item (0 - 15),
    Insert item (0 - 16),
    Insert item (0 - 17),
    Insert item (0 - 18),
    Insert item (0 - 19),
    Insert item (0 - 20),
    Insert item (0 - 21),
    Insert item (0 - 22),
    Insert item (0 - 23),
    Insert item (0 - 24),
    Insert item (0 - 25),
    Insert item (0 - 26),
    Insert item (0 - 27),
    Insert item (0 - 28),
    Insert item (0 - 29),
    Insert item (0 - 30),
    Insert item (0 - 31),
    Insert item (0 - 32),
    Insert item (0 - 33),
    Insert item (0 - 34),
    Insert item (0 - 35),
    Insert item (0 - 36),
    Insert item (0 - 37),
    Insert item (0 - 38),
    Insert item (0 - 39),
    Insert item (0 - 40),
    Insert item (0 - 41),
    Insert item (0 - 42),
    Insert item (0 - 43),
    Insert item (0 - 44),
    Insert item (0 - 45),
    Insert item (0 - 46),
    Insert item (0 - 47),
    Insert item (0 - 48),
    Insert item (0 - 49)
]
Collection view: <UICollectionView: 0x112e56400; frame = (0 0; 0 0); clipsToBounds = YES; gestureRecognizers = <NSArray: 0x2810a2c40>; backgroundColor = UIExtendedGrayColorSpace 0 0; layer = <CALayer: 0x280523e80>; contentOffset: {0, 0}; contentSize: {0, 2448}; adjustedContentInset: {0, 0, 0, 0}; layout: <GiphyUISDK.GPHWaterfallLayout: 0x10acf6c00>; dataSource: <GiphyUISDK.GiphyGridController: 0x159c96a00>>'
*** First throw call stack:
(0x1b4e1ed94 0x1aded43d0 0x1af5c96cc 0x1b7585358 0x1b71905ec 0x1b70eeadc 0x10a0b9434 0x10a0c0148 0x10a0b9dcc 0x1b6eb3f58 0x10a0b8f44 0x10a06dca8 0x1bc2dc320 0x1bc2ddeac 0x1bc2ec6a4 0x1bc2ec2f4 0x1b4eadd18 0x1b4e8f650 0x1b4e944dc 0x1f00f435c 0x1b722037c 0x1b721ffe0 0x100eefa98 0x1d431cdec)
libc++abi: terminating due to uncaught exception of type NSException
* thread #1, queue = 'com.apple.main-thread', stop reason = signal SIGABRT
    frame #0: 0x00000001f3b28558 libsystem_kernel.dylib`__pthread_kill + 8
libsystem_kernel.dylib`:
->  0x1f3b28558 <+8>:  b.lo   0x1f3b28578               ; <+40>
    0x1f3b2855c <+12>: pacibsp 
    0x1f3b28560 <+16>: stp    x29, x30, [sp, #-0x10]!
    0x1f3b28564 <+20>: mov    x29, sp
Target 0: (Runner) stopped.
Lost connection to device.

Thanks in advance

cgmaier commented 1 year ago

hey @Nico3652 thanks for flagging this, sorry for the late reply. I'm using the latest Xcode from the App Store (14.3) and was unable to reproduce based on the code you shared, though I had to add self.addChild(gridController) in order for it to work. Could you try just presenting the GiphyViewController and let me know if that leads to a crash for you as well?

Nico3652 commented 1 year ago

@cgmaier thanks for reply, I'm also using this version of Xcode.

I can confirm that this is not happening presenting the GiphyViewController. I can also confirm it never happen on the first opening : I have to present 3-5x my modal quickly to make it crash.

In order to add more context to my situation, I'm not building an iOS app in full swift code but a Flutter app mixing dart & swift. I suppose you may be not an expert in flutter, but concepts are equals.

The main view I'm using is just a wrapper to insert the gridController : mainView!.addSubview(gridController!.view) It worked perfectly during months.

I cannot use self.addChild(gridController) because the rootVC is in flutter. I'm only retrieving the grid view and add it in my view. Do you think the problem could be linked to this ?

So I have done many tests and here is my workaround : I have removed gridController!.update() from my initController and create another func to update the grid with some delay (1s for now).

This workaround is working great for now until im able to understand this behavior and fix it.

cgmaier commented 1 year ago

hey @Nico3652 glad you found a solution. can you try creating a new GiphyGridController every time? I think the issue may be related to using the same instance.

https://github.com/Giphy/giphy-ios-sdk/blob/main/Docs.md#giphygridcontroller-presentation

Nico3652 commented 1 year ago

@cgmaier I followed the entire documentation, I'm setting to nil the instance every time the page is closed and recreate it inside the init func and only if it's == to nil to avoid duplicate instance

cgmaier commented 1 year ago

are you also removing it from the view hierarchy?

Nico3652 commented 1 year ago

@cgmaier indeed I'm removing it like this :

// Called when the modal is dismissed
    func dismiss(completion: ((Bool?)->Void?)) {
        if(gridController != nil) {
            gridController!.view.removeFromSuperview()
        }
        gridController = nil
        querySearch = nil
        completion(true)
    }

However I'm not an expert in Swift and iOS behavior, am I doing it wrong ?

Here is the full wrapper class I've created for handling the grid :

import Foundation
import GiphyUISDK

class GiphyController: NSObject {

    //var giphy: GiphyViewController?

    var gridController: GiphyGridController?
    var mainView: UIView?
    var content: GPHContent?
    var querySearch: String?
    var currentType: String?
    var channel: FlutterMethodChannel?
    var rootController: UIViewController?

    // Called inside AppDelegate
    func initVC(_ ch: FlutterMethodChannel, _ rootVC: UIViewController) {
        mainView = UIView()
        channel = ch

        //setCacheSize(size: 100)

        // TEST WITH VIEW CONTROLLER
        //
        //              rootController = rootVC
        //              giphy = GiphyViewController()
        //                giphy!.mediaTypeConfig = [.recents, .stickers, .emoji, .text, .gifs]
        //                giphy!.theme = GPHTheme(type: Constants.onDarkMode ? .darkBlur : .lightBlur)
        //                giphy!.stickerColumnCount = GPHStickerColumnCount.three
        //                giphy!.shouldLocalizeSearch = true
        //           rootController?.present(giphy!, animated: true, completion: nil)
        //        return
    }

    // Called only once when the modal show up
    func initController(completion: ((Bool?) -> Void?)) {
        if(gridController == nil) {
            gridController = GiphyGridController()
            gridController!.delegate = self

            // space between cells
            gridController!.cellPadding = 4.0

            // the scroll direction of the grid
            gridController!.direction = .vertical

            // the number of "tracks" is the span count. it represents num columns for vertical grids & num rows for horizontal grids
            gridController!.numberOfTracks = 3

            // hide the checkered background for stickers if you'd like (true by default)
            //gridController!.showCheckeredBackground = false
            gridController!.view.backgroundColor = .clear

            gridController!.theme = GiphyGridTheme()

            // by default, the waterfall layout sizes cells according to the aspect ratio of the media
            // the fixedSizeCells setting makes it so each cell is square
            // this setting only applies to Stickers (not GIFs)
            gridController!.fixedSizeCells = true

            mainView!.addSubview(gridController!.view)

            gridController!.view.translatesAutoresizingMaskIntoConstraints = false

            gridController!.view.leftAnchor.constraint(equalTo: mainView!.safeLeftAnchor).isActive = true
            gridController!.view.rightAnchor.constraint(equalTo: mainView!.safeRightAnchor).isActive = true
            gridController!.view.topAnchor.constraint(equalTo: mainView!.safeTopAnchor).isActive = true
            gridController!.view.bottomAnchor.constraint(equalTo: mainView!.safeBottomAnchor).isActive = true

            completion(true)
        }
        else {
            completion(false)
        }
    }

    // Called when the modal is dismissed
    func dismiss(completion: ((Bool?)->Void?)) {
        if(gridController != nil) {
            gridController!.view.removeFromSuperview()
        }
        gridController = nil
        querySearch = nil
        completion(true)
    }

    func getMedia(id: String, completion: ((String?) -> Void)? = nil) {
        GiphyCore.shared.gifByID(id) { (response, error) in
            if let media = response?.data {
                DispatchQueue.main.sync { [weak self] in
                    let url = media.url(rendition: .fixedWidth, fileType: .gif)
                    completion?(url)
                }
            }
            else {
                completion?(nil)
            }
        }
    }

    func downloadMedia(id: String, completion: ((Data?) -> Void)? = nil) {
        getMedia(id: id, completion: { url in
            if(url != nil) {
                GPHCache.shared.downloadAssetData(url!) { (data, error) in
                    completion?(data)
                }
            }
        })
    }

    func search(query: String, type: String, completion: ((Bool?) ->Void?)) {
        if(gridController != nil) {
            querySearch = query
            let type = getTypeWithString(type: type)
            // TODO : change the localization
            gridController!.content = GPHContent.search(withQuery: query, mediaType: type, language: .french, includeDynamicResults: true)
            gridController!.update()
            completion(true)
        }
        else {
            completion(false)
        }
    }

    func clearSearch(completion: ((Bool?)->Void?)) {
        querySearch = nil
        if(currentType != nil) {
            update(type: currentType!, completion: { success in
                completion(success)
            })
        }
        else {
            completion(false)
        }
    }

    // Called to update the grid content according the current type
    func update(type: String, completion: ((Bool?)->Void?)) {
        if(gridController == nil) {
            completion(false)
            return
        }

        // Only affected here in order to reset the current TAB in grid after clearSearch()
        currentType = type

        // If there is already a keyword in search we just update the content according the query
        // Otherwise moving the content the trend according type
        //
        // If the type is recent or emoji there is no query to make for because this is fixed result
        if(querySearch != nil && type != "recently" && type != "emoji") {
            search(query: querySearch!, type: type,completion: { success in
                completion(success)
            })
            return
        }

        // Update the grid gif according the type
        var content = GPHContent.recents
        switch type {
        case "recently":
            // initialize with recents so don't need to re affect
            break
        case "stickers":
            content = GPHContent.trending(mediaType: .sticker)
            break
        case "emoji":
            content = GPHContent.emoji
            break
        case "text":
            content = GPHContent.trending(mediaType: .text)
            break
        case "gif":
            content = GPHContent.trending(mediaType: .gif)
            break
        default:
            break
        }

        gridController!.content = content
        gridController!.update()
        completion(true)
    }

    func getRecentCount(completion: ((Int?)->Void?)) {
        let count = GPHRecents.count
        completion(count)
    }

    func clearRecent(completion: ((Bool?) -> Void)) {
        GPHRecents.clear()
        completion(true)
    }

    func getTypeWithString(type: String) -> GPHMediaType {
        // The search is not possible when this is recent or emoji because this is fixed list
        // for the user
        switch type {
        case "recently":
            return .sticker
        case "text":
            return .text
        case "stickers":
            return .sticker
        case "emoji":
            return .sticker
        case "gif":
            return .gif
        default:
            return .sticker
        }
    }

    func setCacheSize(size: Int) {
        GPHCache.shared.cache.diskCapacity = size
        GPHCache.shared.cache.memoryCapacity = size
    }

    func clearCache() {
        GPHCache.shared.clear()
    }

}

extension GiphyController: GPHGridDelegate {
    func contentDidUpdate(resultCount: Int, error: Error?) {
    }

    func didSelectMoreByYou(query: String) {
    }

    func didScroll(offset: CGFloat) {
        channel?.invokeMethod("onScroll", arguments: offset)
    }

    func contentDidUpdate(resultCount: Int) {
    }

    func didSelectMedia(media: GPHMedia, cell: UICollectionViewCell) {
        var args = [String: Any]()
        args["id"] = media.id
        args["ratio"] = media.aspectRatio
        channel?.invokeMethod("onMediaPicked", arguments: args)
    }
}

class GiphyGridTheme: GPHTheme {

      override var backgroundColorForLoadingCells: UIColor {

          return .clear

      }

}
ALexanderLonsky commented 3 weeks ago

This issue might be a duplicate of #257