Beyond Code Generation: Crafting Custom Hive Adapters

Dinko MarinacDinko Marinac
6 min read

Hive is the oldest and most used local database in Flutter. While it’s not recommended to use it since it’s not maintained anymore, if you are a consultant or a freelancer you will get a project which still uses it.

Since Github Copilot has come out, I’ve been migrating off using build_runner on larger projects because code generation for 1 change can take a couple of minutes at a time. Projects using hive_generator fall into that category.

To speed things up, I usually write adapters by hand. You might ask yourself, “why”? Using hive_generator is good enough.

The pros outweigh the cons by a large margin:

  • improved build_runner speed

  • cleaner models (no annotations)

  • no need for models only used for database storage

  • easier migration

  • manually written adapters are easier to maintain

  • adapters are easily testable

  • less code overall

I will show you how you can write adapters manually. Using Github Copilot will speed up this process tenfold.

Ready? Let’s dive in.

Hive Crash Course

Before we delve into writing custom adapters, let's quickly recap the basics of Hive for those new to it.

Hive is a lightweight and fast key-value database for Flutter and Dart, allowing you to persistently store objects locally. Here's a brief rundown of how to use Hive:

  1. Setup: First, you need to add the hive and hive_flutter dependencies to your pubspec.yaml file and initialize Hive in your Flutter app.

     dependencies:
       hive: ^2.2.3
       hive_flutter: ^1.1.0
    
     ## you will not be needing this since you will learn how to write adapters by yourself  
     dev_dependencies:
         hive_generator: ^2.0.1
    
  2. Model Definition: Define your data model classes that you want to persist in Hive. When you are using a generator, these classes should extend HiveObject and have fields that are supported by Hive.

         import 'package:hive_flutter/hive_flutter.dart';
    
         part 'user.g.dart';
    
         @HiveType(typeId: 0)
         class User extends HiveObject {
           @HiveField(0)
           final int id;
    
           @HiveField(1)
           final String name;
    
           @HiveField(2)
           final String profilePictureUrl;
    
           @HiveField(3)
           final bool isPremium;
    
           User({required this.id, required this.name, required this.profilePictureUrl, required this.isPremium});
         }
    
  3. Adapter Registration: Register adapters for your model classes.

     // Initialize Hive
     await Hive.initFlutter();
    
     // Register adapter for User class
     Hive.registerAdapter<User>(UserAdapter());
    
  4. Box: Open a Hive box to store your data. Boxes act like tables in traditional databases and are where you store your objects.

     // Open a box named 'users'
     var box = await Hive.openBox<User>('users');
    
     // If you have a very large collection, use lazyBox
     await Hive.openLazyBox('users');
     final lazyBox = Hive.lazyBox('users');
    
  5. CRUD Operations: Perform CRUD (Create, Read, Update, Delete) operations on your data by putting, getting, updating, and deleting objects in/from the box.

     // Store/update the user in the box
     await box.put('john', user);
    
     // Retrieve the user from the box
     var retrievedUser = box.get('john');
     print(retrievedUser!.name); // Output: John
    
     // Delete the user
     await box.delete('john');
    
  6. Box close: Close the Box when you are done with using the data.

     await box.close();
    

Writing custom adapters for any class

Custom adapters in Hive allow you to define how objects of your custom classes are serialized and deserialized into binary format for storage. Let's walk through an example of creating a class and writing a custom adapter for it.

I will use the previously mentioned User class without the Hive annotations:

class User {
  final int id;
  final String name;
  final String profilePictureUrl;
  final bool isPremium;

  User({
    required this.id,
    required this.name,
    required this.profilePictureUrl,
    required this.isPremium,
  });
}

To write a custom adapter, you have to 3 things:

  • extend TypeAdapter with an appropriate type (User in this case)

  • give the adapter a typeId

  • make sure you are writing and reading properties in the same order

Reading and writing in a different order will cause your data to be corrupt if it’s the same type, or it throws an exception because casting to the type you want will not be possible.

Let’s look at the example of how to properly write an adapter:

import 'package:hive_flutter/hive_flutter.dart';

class UserAdapter extends TypeAdapter<User> {
  UserAdapter(this.typeId);

  @override
  final int typeId;

  @override
  User read(BinaryReader reader) {
    final id = reader.read() as int;
    final name = reader.read() as String;
    final profilePictureUrl = reader.read() as String;
    final isPremium = reader.read() as bool;

    return User(
        id: id,
        name:name,
        profilePictureUrl: profilePictureUrl,
        isPremium: Premium,
      );
  }

  @override
  void write(BinaryWriter writer, User obj) {
    writer
      ..write(obj.id)
      ..write(obj.name)
      ..write(obj.profilePictureUrl)
      ..write(obj.isPremium);
  }
}

In the above adapter:

  • We specify the typeId to uniquely identify the adapter.

  • The read method reads data from the binary and constructs a User object.

  • The write method writes the User object to the binary.

Now, let's compare the manually written adapter with the code generated by Hive for the User class.

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'user.dart';

// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************

class UserAdapter extends TypeAdapter<User> {
  @override
  final int typeId = 0;

  @override
  User read(BinaryReader reader) {
    final numOfFields = reader.readByte();
    final fields = <int, dynamic>{
      for (var i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
    };
    return User(
      id: fields[0] as int,
      name: fields[1] as String,
      profilePictureUrl: fields[2] as String,
      isPremium: fields[3] as bool,
    );
  }

  @override
  void write(BinaryWriter writer, User obj) {
    writer
      ..writeByte(4)
      ..writeByte(0)
      ..write(obj.id)
      ..writeByte(1)
      ..write(obj.name)
      ..writeByte(2)
      ..write(obj.profilePictureUrl)
      ..writeByte(3)
      ..write(obj.isPremium);
  }
}

The code that we wrote is almost the same expect for one key difference: Hive uses numbers associated with the field to make sure the fields are serialized and deserialized in the correct order. They are also used in case you need to migrate.

Special case: Enums

Hive doesn’t support enums of the box, so we have to serialize them as well. Luckily, we can do this in a very generic way for non-enhanced enums.

There are 2 ways we can solve this problem:

  1. Taking advantage of values, which always returns the list in the order that is written in the code. That way we only need to save the index of the value.

     import 'package:hive_flutter/hive_flutter.dart';
    
     class EnumClassAdapter<T extends Enum> extends TypeAdapter<T> {
       EnumClassAdapter(this.typeId, this.values);
    
       @override
       final int typeId;
    
       final List<T> values;
    
       @override
       T read(BinaryReader reader) {
         return values[reader.read() as int];
       }
    
       @override
       void write(BinaryWriter writer, T obj) {
         writer.write(obj.index);
       }
     }
    
  2. Taking advantage of name property which returns the value of the enum as a String and then searches the values for it, which eliminates the need to store the index correctly.

     import 'package:hive_flutter/hive_flutter.dart';
    
     class EnumClassAdapter<T extends Enum> extends TypeAdapter<T> {
       EnumClassAdapter(this.typeId, this.values);
    
       @override
       final int typeId;
    
       final List<T> values;
    
       @override
       T read(BinaryReader reader) {
         final enumString = reader.read() as String;
         return values.firstWhere((e) => e.name == enumString);
       }
    
       @override
       void write(BinaryWriter writer, T obj) {
         writer.write(obj.name);
       }
     }
    

Then all you have to do is register the adapter and just cast the class as usual when writing other adapters:

enum Category { one, two, three }

Hive.registerAdapter<Category>(EnumClassAdapter<Category>(1, Category.values));
await Hive.initFlutter();

I usually chose option number 2 because it is less error-prone. If you choose option 1, you must not change the order of the enum values because your data will become corrupted.

Conclusion

In this article, we've explored the process of writing custom Hive adapters manually. Although code generation tools like hive_generator exist, manually written adapters offer several advantages, including improved build speed, cleaner models, easier migration, and better maintainability.

By understanding how to write custom adapters, you have greater control over how your data is serialized and deserialized, allowing you to tailor the storage mechanism to your specific needs.

So next time you're working on a Flutter project with Hive, consider crafting your adapters by hand for a more efficient and flexible solution.

If you have found this useful, make sure to like and follow for more content like this. To know when the new articles are coming out, follow me on Twitter or LinkedIn.

Until next time, happy coding!

0
Subscribe to my newsletter

Read articles from Dinko Marinac directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Dinko Marinac
Dinko Marinac

Mobile app developer and consultant. CEO @ MOBILAPP Solutions. Passionate about the Dart & Flutter ecosystem.