Advanced caching using a database

This section shows you how to build your own local cache using a database. This has several advantages like storing raw data in a file and enabling queries on stored channels and messages. Our examples are written based on the SQLite, and it's not difficult to follow these steps with any database of your choice, such as Realm.


Serialize and deserialize SendBird objects

To store SendBird objects such as messages, channels, and users in local storage, we provide serialization and deserialization methods through our SDK. Use serialize to convert a SendBird object to binary data, which can then be natively stored in your persistent database.

// In SBDBaseMessage.h
- (nullable NSData *)serialize;
+ (nullable instancetype)buildFromSerializedData:(NSData * _Nonnull)data;

// In SBDBaseChannel.h
- (nullable NSData *)serialize;
+ (nullable instancetype)buildFromSerializedData:(NSData * _Nonnull)data;

Save and load messages with serialization and deserialization

Design a message table

A basic table to store messages contains the following columns:

message_id channel_url message_ts payload
123123 sendbird_channel_234802384 1432039402493 Serialized data
234234 sendbird_channel_234802384 1432039403243 Serialized data

Caching procedures

  1. After fetching new messages using getNextMessagesByTimestamp:limit:reverse:completionHandler:, getPreviousMessagesByTimestamp:limit:reverse:completionHandler:, and getPreviousAndNextMessagesByTimestamp:prevLimit:nextLimit:reverse:completionHandler: method, serialize and insert each message into your database. However, we recommend to store the message ID, timestamp, and channel URL in separate columns by using message.messageId, message.createdAt, and message.channelUrl. This allows you to query the dataset later on.

  2. Before loading messages within a channel, order rows chronologically by message_ts. Then, deserialize each message and display them in your UI.

  3. When loading previous messages that are not currently stored in the local database, obtain the timestamp of the earliest stored message. Then, query for messages created before that value.

  4. Likewise, when loading new messages, query for messages with a later timestamp than the most recent message.

Example 1: When entering a channel
// Get messages from local database
sqlite3 *contactDB;
char *dbpath = "<DATABASE_PATH>";
char *query;
sqlite3_stmt *stmt;

if (sqlite3_open(dbpath, &contactDB) == SQLITE_OK) {
    if (order) {
        query = "SELECT * FROM (SELECT * FROM MESSAGE WHERE COLUMN_NAME_CHANNEL_URL = ? AND COLUMN_NAME_TIMESTAMP < ? ORDER BY COLUMN_NAME_TIMESTAMP DESC LIMIT ?) ORDER BY COLUMN_NAME_TIMESTAMP ASC";
    }
    else {
        query = "SELECT * FROM (SELECT * FROM MESSAGE WHERE COLUMN_NAME_CHANNEL_URL = ? AND COLUMN_NAME_TIMESTAMP < ? ORDER BY COLUMN_NAME_TIMESTAMP DESC LIMIT ?) ORDER BY COLUMN_NAME_TIMESTAMP DESC";
    }

    if (sqlite3_prepare_v2(contactDB, query, -1, &stmt, nil) == SQLITE_OK) {
        sqlite3_bind_text(stmt, 1, [@"<CHANNEL_URL>" UTF8String], -1, SQLITE_TRANSIENT); // COLUMN_NAME_CHANNEL_URL
        sqlite3_bind_int64(stmt, 2, timestamp); // COLUMN_NAME_TIMESTAMP
        sqlite3_bind_int64(stmt, 3, limit); // COLUMN_NAME_TIMESTAMP

        // Create a List of SBDBaseMessage by deserializing each item.
        NSMutableArray<SBDBaseMessage *> *prevMessageList = [[NSMutableArray alloc] init];

        while (sqlite3_step(stmt) == SQLITE_ROW) {
            const char *payload = sqlite3_column_blob(stmt, 4);

            int size = sqlite3_column_bytes(stmt, 4);
            data = [[NSData alloc] initWithBytes:payload length:size];

            SBDBaseMessage *message = [SBDBaseMessage buildFromSerializedData:data];
            [prevMessageList addObject:message];
        }
        sqlite3_finalize(stmt);

        // Pass messages to data source for displaying them in a UITableView, UICollectionView, etc.
        [self.messageList addObjects:prevMessageList];

        // Get new messages from the SendBird servers
        long latestStoredTs = prevMessageList[0].createdAt; // Get the timestamp of the last stored message.

        [self.channel getNextMessagesByTimestamp:latestStoredTs limit:30 reverse:NO completionHandler:^(NSArray<SBDBaseMessage *> * _Nullable messages, SBDError * _Nullable error) {
            if (error != nil) {
                // Error!
                return;
            }

            // New messages successfully fetched.
            [self.messageList addObjects:messages];

            // Insert each new message in your local database
            const char *query = "INSERT INTO MESSAGE (message_id, channel_url, message_ts, payload) VALUES (?, ?, ?, ?)";

            for (SBDBaseMessage *message in messages) {
                // Store each new message into the local database
                if (sqlite3_prepare_v2(contactDB, query, -1, &stmt, nil) == SQLITE_OK) {
                    sqlite3_bind_int64(stmt, 1, message.messageId); // message_id
                    sqlite3_bind_text(stmt, 2, [message.channelUrl UTF8String], -1, SQLITE_TRANSIENT); // channel_url
                    sqlite3_bind_int64(stmt, 3, message.createdAt); // message_ts

                    NSData *blob = [message serialize];
                    sqlite3_bind_blob(stmt, 4, [blob bytes], [blob length], SQLITE_TRANSIENT);
                }    

                if (sqlite3_step(stmt) != SQLITE_DONE) {
                    // Error!
                }

                sqlite3_finalize(stmt);
            }
        }];
    }

    sqlite3_finalize(stmt);
}

sqlite3_close(contactDB);
Example 2: When receiving new messages
- (void)channel:(SBDBaseChannel * _Nonnull)sender didReceiveMessage:(SBDBaseMessage * _Nonnull)message {
    if (sender == self.channel) {
        // Pass the message to your data source for UITableView or UICollectionView.
        [self.messageList addObject:message];

        // Store the message in your local database.
        // It is a good idea to have a helper class or method for database transactions.
        sqlite3 *contactDB;
        char *dbpath = "<DATABASE_PATH>";
        char *query;
        sqlite3_stmt *stmt;

        if (sqlite3_open(dbpath, &contactDB) == SQLITE_OK) {
            // Insert each new message in your local database
            const char *query = "INSERT INTO MESSAGE (message_id, channel_url, message_ts, payload) VALUES (?, ?, ?, ?)";

            // Store each new message into the local database
            if (sqlite3_prepare_v2(contactDB, query, -1, &stmt, nil) == SQLITE_OK) {
                sqlite3_bind_int64(stmt, 1, message.messageId); // message_id
                sqlite3_bind_text(stmt, 2, [message.channelUrl UTF8String], -1, SQLITE_TRANSIENT); // channel_url
                sqlite3_bind_int64(stmt, 3, message.createdAt); // message_ts

                NSData *blob = [message serialize];
                sqlite3_bind_blob(stmt, 4, [blob bytes], [blob length], SQLITE_TRANSIENT);
            }    

            if (sqlite3_step(stmt) != SQLITE_DONE) {
                // Error!
            }

            sqlite3_finalize(stmt);
        }

        sqlite3_close(contactDB);
    }
}
Example 3: When sending a message
[self.channel sendUserMessage:messageBody completionHandler:^(SBDUserMessage * _Nullable userMessage, SBDError * _Nullable error) {
    if (error != nil) {
        // Error!
        return;
    }

    // Pass the message to your data source for UITableView or UICollectionView.
    [self.messageList addObject:message];

    // Store the message in your local database.
    // It is a good idea to have a helper class or method for database transactions.
    sqlite3 *contactDB;
    char *dbpath = "<DATABASE_PATH>";
    char *query;
    sqlite3_stmt *stmt;

    if (sqlite3_open(dbpath, &contactDB) == SQLITE_OK) {
        // Insert each new message in your local database
        const char *query = "INSERT INTO MESSAGE (message_id, channel_url, message_ts, payload) VALUES (?, ?, ?, ?)";

        // Store each new message into the local database
        if (sqlite3_prepare_v2(contactDB, query, -1, &stmt, nil) == SQLITE_OK) {
            sqlite3_bind_int64(stmt, 1, message.messageId); // message_id
            sqlite3_bind_text(stmt, 2, [message.channelUrl UTF8String], -1, SQLITE_TRANSIENT); // channel_url
            sqlite3_bind_int64(stmt, 3, message.createdAt); // message_ts

            NSData *blob = [message serialize];
            sqlite3_bind_blob(stmt, 4, [blob bytes], [blob length], SQLITE_TRANSIENT);
        }    

        if (sqlite3_step(stmt) != SQLITE_DONE) {
            // Error!
        }

        sqlite3_finalize(stmt);
    }

    sqlite3_close(contactDB);
}];

Caveats

Currently, it is difficult to sync deleted or edited messages. We are working to provide this feature in both our SDKs and APIs, and hope to release it soon.


Save and load channels with serialization and deserialization

Note: The examples in this section are based on the Group Channel. To cache the Open Channel, slightly improvise from the directions below (such as changing last_message_ts to channel_created_at).

Design a channel table

A basic table to store channels contains the following columns:

channel_url last_message_ts payload
sendbird_channel_234802384 1432039402729 Serialized data
sendbird_channel_234802384 1432039403448 Serialized data

Caching procedures

  1. After fetching new channels using a SBDOpenChannelListQuery or SBDGroupChannelListQuery, serialize and insert each channel into your database. As with messages, we recommend to store the channel URL and timestamp of the last message in separate columns by using channel.channelUrl and channel.lastMessage.createdAt. This allows you to query the dataset later on.

  2. Before loading a list of channels, order rows chronologically by last_message_ts. Then, deserialize each channel and display them in your UI.

  3. Unlike messages, channels are relatively few in number and go through frequent property changes, such as cover URL changes, name changes, or deletions. Therefore, we recommend updating your cache by completely replacing the dataset when possible.

  4. When real-time changes are made to a channel list, update your cache.

Example 1: When entering the channel list screen
// Load channels from local database
sqlite3 *contactDB;
char *dbpath = "<DATABASE_PATH>";
char *query;
sqlite3_stmt *stmt;

if (sqlite3_open(dbpath, &contactDB) == SQLITE_OK) {
    if (order) {
        query = "SELECT * FROM CHANNEL ORDER BY COLUMN_NAME_LAST_MESSAGE_TIMESTAMP ASC";
    }
    else {
        query = "SELECT * FROM CHANNEL ORDER BY COLUMN_NAME_LAST_MESSAGE_TIMESTAMP DESC";
    }

    if (sqlite3_prepare_v2(contactDB, query, -1, &stmt, nil) == SQLITE_OK) {
        // Create a List of `SBDBaseChannel`s by deserializing each item.
        NSMutableArray<SBDBaseChannel *> *prevChannelList = [[NSMutableArray alloc] init];

        while (sqlite3_step(stmt) == SQLITE_ROW) {
            const char *payload = sqlite3_column_blob(stmt, 3);

            int size = sqlite3_column_bytes(stmt, 3);
            data = [[NSData alloc] initWithBytes:payload length:size];

            SBDBaseChannel *channel = [SBDBaseChannel buildFromSerializedData:data];
            [prevChannelList addObject:channel];
        }
        sqlite3_finalize(stmt);

        sqlite3_close(contactDB);

        // Pass messages to data source for displaying them in a UITableView, UICollectionView, etc.
        [self.channelList addObjects:prevChannelList];

        // Get new channels from the SendBird servers
        SBDGroupChannelListQuery *query = [SBDGroupChannel createMyGroupChannelListQuery];
        [query loadNextPageWithCompletionHandler:^(NSArray<SBDGroupChannel *> * _Nullable channels, SBDError * _Nullable error) {
            if (error != nil) {
                // Error!
                return;
            }

            // Replace the current (cached) dataset
            [self.channelList removeAllObjects];
            [self.channels addObjects:channels];

            sqlite3 *contactDB;
            char *dbpath = "<DATABASE_PATH>";
            char *query;
            sqlite3_stmt *stmt;

            // Clear the current cache


            if (sqlite3_open(dbpath, &contactDB) == SQLITE_OK) {
                // Insert each new channel in your local database
                const char *query = "INSERT INTO CHANNEL (channel_url, last_message_ts, payload) VALUES (?, ?, ?)";
                for (SBDGroupChannel *channel in channels) {
                    // Store each new channel into the local database
                    if (sqlite3_prepare_v2(contactDB, query, -1, &stmt, nil) == SQLITE_OK) {
                        sqlite3_bind_text(stmt, 1, [channel.channelUrl UTF8String], -1, SQLITE_TRANSIENT); // channel_url
                        sqlite3_bind_int64(stmt, 2, channel.lastMessage.createdAt); // last_message_ts

                        NSData *blob = [channel serialize];
                        sqlite3_bind_blob(stmt, 3, [blob bytes], [blob length], SQLITE_TRANSIENT);
                    }    

                    if (sqlite3_step(stmt) != SQLITE_DONE) {
                        // Error!
                    }

                    sqlite3_finalize(stmt);
                }
            }

            sqlite3_close(contactDB);
        }];
    }
}
Example 2: On real-time events such as additions or updates
- (void)channelWasChanged:(SBDBaseChannel * _Nonnull)sender {
    if ([sender isKindOfClass:[SBDGroupChannel class]]) {
        sqlite3 *contactDB;
        char *dbpath = "<DATABASE_PATH>";
        char *query;
        sqlite3_stmt *stmt;

        if (sqlite3_open(dbpath, &contactDB) == SQLITE_OK) {
            query = "SELECT * FROM CHANNEL WHERE channel_url = ?";
            if (sqlite3_prepare_v2(contactDB, query, -1, &stmt, nil) == SQLITE_OK) {
                sqlite3_bind_text(stmt, 1, [sender.channelUrl UTF8String], -1, SQLITE_TRANSIENT); // channel_url
            }

            if (sqlite3_step(stmt) == SQLITE_ROW) {
                // If the channel is not currently cached, add it.
                sqlite3_finalize(stmt);

                const char *query = "INSERT INTO CHANNEL (channel_url, last_message_ts, payload) VALUES (?, ?, ?)";
                for (SBDGroupChannel *channel in channels) {
                    // Store each new channel into the local database
                    if (sqlite3_prepare_v2(contactDB, query, -1, &stmt, nil) == SQLITE_OK) {
                        sqlite3_bind_text(stmt, 1, [sender.channelUrl UTF8String], -1, SQLITE_TRANSIENT); // channel_url
                        sqlite3_bind_int64(stmt, 2, ((SBDGroupChannel *)sender).lastMessage.createdAt); // last_message_ts

                        NSData *blob = [sender serialize];
                        sqlite3_bind_blob(stmt, 3, [blob bytes], [blob length], SQLITE_TRANSIENT);
                    }    

                    if (sqlite3_step(stmt) != SQLITE_DONE) {
                        // Error!
                    }

                    sqlite3_finalize(stmt);
                }
            }
            else {
                // If the channel is in the current cache, update it.
                sqlite3_finalize(stmt);
                query = "UPDATE CHANNEL SET last_message_ts = ?, payload = ? WHERE channel_url = ?";
                if (sqlite3_prepare_v2(contactDB, query, -1, &stmt, nil) == SQLITE_OK) {
                    sqlite3_bind_int64(stmt, 1, ((SBDGroupChannel *)sender).lastMessage.createdAt); // last_message_ts

                    NSData *blob = [sender serialize];
                    sqlite3_bind_blob(stmt, 2, [blob bytes], [blob length], SQLITE_TRANSIENT);

                    sqlite3_bind_text(stmt, 3, [sender.channelUrl UTF8String], -1, SQLITE_TRANSIENT); // channel_url

                    if (sqlite3_step(stmt) != SQLITE_DONE) {
                        // Error!
                    }

                    sqlite3_finalize(stmt);
                }
            }
        }

        sqlite3_close(contactDB);
    }
}

Note: A similar process can be followed for channelWasDeleted:channelType:, channel:userDidJoin:, and channel:userDidLeave: method.