Skip to content

About

Fast persistant thread-safe binary storage

Resources

Stars

3 stars

Watchers

0 watching

Forks

Latest commit

 

History

64 Commits

Folders and files

Repository files navigation

💾 Fast binary prefs for Unity3d

openupm

Almost every project needs to persist small pieces of data: player progress, settings, the last opened screen, feature flags. The default tool for this in Unity is PlayerPrefs, and for anything beyond a couple of values it gets painful fast.

Unity's standard PlayerPrefs has several limitations:

  • It supports only three types: int, float and string. No bool, no Vector3, no DateTime, no enums, and no collections.
  • It is not type-safe. Nothing stops you from writing a value with SetInt and reading it back with GetString - you just get a wrong value at runtime, with no error.
  • It stores data in platform-specific locations (Windows registry, macOS plist, ...) as text, which is slow, bloated, and awkward to inspect or ship with the project.
  • It has no way to remove keys by pattern, no change notifications, and no control over when data is written - saving is all-or-nothing.
  • There is no way to group related keys, so everything lives in one flat global namespace.

All of these issues are addressed by BinaryPrefs: a configurable, strongly typed, binary key-value storage with support for Unity types, enums, collections, custom serializers, change events, and scoped sub-storages.

And the feature I like most: every change is saved on its own. As soon as you set a value - or even mutate a stored list, set or dictionary - it is written to disk a moment later on a background thread, with atomic writes. You never have to remember to call Save(), and the calling thread never waits for the disk. When you need it, many changes can still be batched into a single write.

Table of content

Package installation

Using OpenUPM

Using OpenUPM-CLI run the command

openupm add com.appegy.binary-prefs

Alternatively, you can install the package manually by following the instructions on the package page.

Using Git link

Add the package to your manifest.json.

"dependencies": {
  "com.appegy.binary-prefs": "https://github.com/appegy/binaryprefs.git?path=/src",
  ...
},

Quick start

The simplest way to get a storage with all primitive types and auto-save enabled:

using System.IO;
using Appegy.Storage;
using UnityEngine;

var path = Path.Combine(Application.persistentDataPath, "player.bin");

// Pre-configured: primitive types + auto-save on change.
using var storage = BinaryStorage.Get(path);

storage.Set("player_score", 100);
storage.Set("player_speed", 5.5f);
storage.Set("player_name", "John Doe");

int score = storage.Get("player_score", 0);
float speed = storage.Get("player_speed", 1.0f);
string name = storage.Get("player_name", "Unknown");

BinaryStorage implements IDisposable. Dispose it (e.g. with using) to flush and release the file. In the Editor the file path is locked while a storage instance is open, preventing accidental concurrent access to the same file.

Configuring storage

For full control use the fluent builder via BinaryStorage.Construct:

using var storage = BinaryStorage.Construct(path)
    .AddPrimitiveTypes()                                   // built-in C# and Unity types
    .SupportEnum<GameState>()                              // an enum
    .SupportListsOf<int>()                                 // list of a supported type
    .SupportSetsOf<string>()                               // set of a supported type
    .SupportDictionariesOf<string, int>()                 // dictionary of supported types
    .SetMissingKeyBehaviour(MissingKeyBehavior.ReturnDefaultValueOnly)
    .SetTypeMismatchBehaviour(TypeMismatchBehaviour.OverrideValueAndType)
    .SetCorruptedFileBehaviour(CorruptedFileBehaviour.ResetToEmptyWithError)
    .EnableAutoSaveOnChange()
    .SaveOnBackgroundThread(true)                          // on by default, false writes the file before every change returns
    .Build(KeyLoadFailedBehaviour.IgnoreWithWarning);

AddPrimitiveTypes registers all C# primitives plus common Unity types: bool, char, all integer types, float, double, decimal, string, DateTime, TimeSpan, Quaternion, Vector2/3/4, Vector2Int and Vector3Int.

Notes:

  • SupportListsOf<T> / SupportSetsOf<T> / SupportDictionariesOf<TKey, TValue> require the element types to be registered first (e.g. via AddPrimitiveTypes or AddTypeSerializer); otherwise a CantSupportCollectionOfException is thrown.
  • AddTypeSerializer throws if a serializer for that type, or with the same type name, is already registered.

To persist your own type, register a serializer for it:

.AddTypeSerializer(myCustomSerializer)   // a TypeSerializer<MyType>

Reading and writing

storage.Set("level", 7);                      // returns bool: true if the value was written
bool exists = storage.Has("level");           // key present?
System.Type type = storage.TypeOf("level");   // stored type, or null if absent
int level = storage.Get("level", 1);          // typed read with default

storage.Remove("level");                       // remove one key
storage.Remove(key => key.StartsWith("tmp_")); // remove by predicate, returns count
storage.RemoveAll();                           // clear everything

Untyped access is available via GetRaw / SetRaw when the type is only known at runtime:

object raw = storage.GetRaw("level");
storage.SetRaw("level", 10);

Collections

Collections returned by the storage are live: mutating them updates the storage (and triggers auto-save if enabled).

IList<int> scores = storage.GetListOf<int>("scores");
scores.Add(100);
scores.Add(250);

ISet<string> tags = storage.GetSetOf<string>("tags");
tags.Add("vip");

IDictionary<string, int> inventory = storage.GetDictionaryOf<string, int>("inventory");
inventory["coins"] = 50;

Read-only views are also available: GetReadOnlyListOf<T>, GetReadOnlySetOf<T>, GetReadOnlyDictionaryOf<TKey, TValue>.

Batch changes

When changing many keys at once, wrap them in a scope so the storage saves only once at the end instead of on every change:

using (storage.MultipleChangeScope())
{
    storage.Set("a", 1);
    storage.Set("b", 2);
    storage.Set("c", 3);
} // saved here once (when auto-save is enabled)

Saving

By default (and with BinaryStorage.Get) auto-save is enabled, so every change goes to disk on its own - the moment you Set a value or mutate a stored collection. Forget-to-save bugs simply don't happen.

The change is serialized on your thread and handed to a shared background writer, so Set returns without waiting for the disk. A newer change replaces an older one that hasn't been written yet, because the file is always written whole and only the last state matters.

Writes are atomic: the data goes to a .tmp file first, is flushed to the disk, and only then replaces the storage file. The previous save stays next to it as a .bak file. A process killed in the window between the change and the write loses only the last change.

On load, if the storage file cannot be read, the storage takes the .bak file instead, and if a save was stopped while the files were being swapped, it takes the .tmp file. When nothing can be read, CorruptedFileBehaviour decides what happens.

Need to apply many changes as a single write? Wrap them in a change scope - auto-save then fires once, when the scope ends.

If you prefer full manual control, skip EnableAutoSaveOnChange and persist yourself. Save() blocks until the data has actually reached the disk - it writes the file on your thread rather than queueing behind the background writer:

storage.Save();

Disposing a storage writes out whatever is still pending. To go back to writing the file before every change returns, turn the background writer off:

using var storage = BinaryStorage.Construct(path)
    .AddPrimitiveTypes()
    .SaveOnBackgroundThread(false)
    .Build();

Change events

storage.OnKeyAdded   += key => Debug.Log($"Added: {key}");
storage.OnKeyChanged += key => Debug.Log($"Changed: {key}");
storage.OnKeyRemoved += key => Debug.Log($"Removed: {key}");

Nested storage

CreateChild returns an IBinaryStorage view scoped under a prefix, useful for grouping keys (e.g. per player or per feature) while sharing one file:

IBinaryStorage player1 = storage.CreateChild("player1");
player1.Set("score", 100); // stored under the "player1" prefix in the same file

Behavior reference

MissingKeyBehavior

Controls what Get<T> does when the key is absent.

  • InitializeWithDefaultValue - store the provided default and return it.
  • ReturnDefaultValueOnly - return the default without storing it.

TypeMismatchBehaviour

Controls what happens when a key already exists with a different type.

  • ThrowException - throw on mismatch.
  • OverrideValueAndType - replace the stored value and its type.
  • Ignore - keep the existing value, ignore the new one.

KeyLoadFailedBehaviour

Passed to Build; controls what happens when a key fails to deserialize on load.

  • ThrowException - abort loading with an exception.
  • Ignore - skip the bad key silently.
  • IgnoreWithWarning - skip the bad key and log a warning (default).

CorruptedFileBehaviour

Controls what Build does when neither the storage file nor its backup can be read. In every mode the unreadable files are removed, so the next start is clean. An error on opening the file (no access, the file is locked) is not a corrupted file: it always reaches the caller and the files stay.

  • ThrowException - throw a StorageFileCorruptedException.
  • ResetToEmpty - start with an empty storage.
  • ResetToEmptyWithError - start with an empty storage and log the exception (default).

Per-call overrides are available too: Get<T>(key, default, overrideMissingKeyBehavior) and Set<T>(key, value, overrideTypeMismatchBehaviour).

License

MIT. See LICENSE.

About

Fast persistant thread-safe binary storage

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages