flutter .hive 파일 분석

flutter .hive 파일 분석

2024년 12월 2일

.hive 파일 #

Hive란 무엇인가 #

Flutter에서 Hive 라는 경량 데이터베이스가 사용하는 파일 형식으로, 그냥 객체를 파일에 직렬화된 형태로 저장하고, 불러올 수 있다.

일반적인 타입의 경우 이미 정의된 형식으로 읽고 쓸 수 있으며, 커스텀 타입의 경우 개발자가 typeId를 지정하여 원하는 타입의 클래스를 생성한 후 TypeAdapter를 함께 생성해 직렬화/역직렬화 방식을 정의한다.

파일은 보통 /data/data/<package_name>/app_flutter/<box_name>.hive 경로에 저장된다.


커스텀타입 사용방법 #

일반적인 타입을 key, value 형태로 hive에 저장할 수 있지만, 개발자의 코드에서 런타임에 타입레지스터로 원하는 타입어댑터를 등록해야한다.

  1. 커스텀 타입 코드를 작성한다 typeId는 0 부터 작성하며, 다른 커스텀 타입과 중복되면 안된다.
 1// lib/address.dart
 2@HiveType(typeId: 1) // 고유 ID
 3class Address {
 4    @HiveField(0)
 5    String street;
 6    @HiveField(1)
 7    String city;
 8    @HiveField(2)
 9    String country;
10
11    Address(this.street, this.city, this.country);
12}
  1. 타입 어댑터를 생성한다.
    flutter packages pub run build_runner build 명령을 실행하면 자동으로 타입 어댑터가 생성된다.
 1// lib/address.g.dart
 2class AddressAdapter extends TypeAdapter<Address> {
 3  @override
 4  final int typeId = 1;
 5
 6  @override
 7  Address read(BinaryReader reader) {
 8    final numOfFields = reader.readByte();
 9    final fields = <int, dynamic>{
10      for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
11    };
12    return Address(
13      fields[0] as String,
14      fields[1] as String,
15      fields[2] as String,
16    );
17  }
18
19  @override
20  void write(BinaryWriter writer, Address obj) {
21    writer
22      ..writeByte(3)
23      ..writeByte(0)
24      ..write(obj.street)
25      ..writeByte(1)
26      ..write(obj.city)
27      ..writeByte(2)
28      ..write(obj.country);
29  }
30
31  @override
32  int get hashCode => typeId.hashCode;
33
34  @override
35  bool operator ==(Object other) =>
36      identical(this, other) ||
37      other is AddressAdapter &&
38          runtimeType == other.runtimeType &&
39          typeId == other.typeId;
40}
  1. 타입어댑터를 등록하고 사용한다. 이후에 read, write 함수에서 _typeRegistry.findAdapterForValue(value); 또는 _typeRegistry.findAdapterForTypeId(typeId); 형식으로 호출해서 맞는 타입 어댑터를 가져온다.
 1void main() async {
 2  WidgetsFlutterBinding.ensureInitialized();
 3  await Hive.initFlutter();
 4
 5  // 타입어댑터 등록 
 6  Hive.registerAdapter(PersonAdapter());
 7  Hive.registerAdapter(AddressAdapter());
 8  Hive.registerAdapter(DescriptionAdapter());
 9  Hive.registerAdapter(GenderAdapter());
10  await Hive.openBox<Person>('personBox');
11
12  runApp(const MyApp());
13}
  1. HiveImpl이 typeRegistryImpl을 상속받기 때문에 어댑터는 Hive에서 관리된다.
 1// hive/lib/src/hive_impl.dart
 2class HiveImpl extends TypeRegistryImpl implements HiveInterface {
 3    ...
 4}
 5
 6// hive/lib/src/registry/type_registry_impl.dart
 7class TypeRegistryImpl implements TypeRegistry {
 8    final _typeAdapters = <int, ResolvedAdapter>{};
 9    static const reservedTypeIds = 32;
10
11    @override
12    void registerAdapter<T>(TypeAdapter<T> adapter, { bool internal = false, bool override = false }) {
13        ...
14        typeId = typeId + reservedTypeIds;  // 여기에서 0x20이 더해진다. 
15        var resolved = ResolvedAdapter<T>(adapter, typeId);
16        _typeAdapters[typeId] = resolved;
17    }
18}

파일에 쓰는 로직 #

  1. 개발자의 코드에서 box를 열고 데이터를 쓴다.
1// lib/main.dart
2Hive.openBox<Person>('personBox');
3...
4await personBox.put(person.id, person);
  1. 프레임을 만들고, 백엔드를 이용해서 파일에 쓴다.
 1// hive/lib/src/box/box_impl.dart
 2@override
 3Future<void> putAll(Map<dynamic, E> kvPairs) {
 4    var frames = <Frame>[];
 5    for (var key in kvPairs.keys) {
 6        frames.add(Frame(key, kvPairs[key]));
 7    }
 8    return _writeFrames(frames);
 9}
10
11Future<void> _writeFrames(List<Frame> frames) async {
12    checkOpen();
13    if (!keystore.beginTransaction(frames)) return;
14    try {
15        await backend.writeFrames(frames);
16        keystore.commitTransaction();
17    } catch (e) {
18        keystore.cancelTransaction();
19        rethrow;
20    }
21    await performCompactionIfNeeded();
22}
  1. backend에서 프레임을 쓸때 BinaryWriter 를 이용한다.
 1// hive/lib/src/backend/vm/storage_backend_vm.dart
 2@override
 3Future<void> writeFrames(List<Frame> frames) {
 4    return _sync.syncWrite(() async {
 5    var writer = BinaryWriterImpl(registry);
 6
 7    for (var frame in frames) {
 8        frame.length = writer.writeFrame(frame, cipher: _cipher);
 9    }
10    ...
11}
  1. BinaryWriter에서 하나의 프레임은 Key + Data + CRC 형태로 쓰게된다.
    이 말은 Frame = Box 라는 의미이다.
 1// hive/lib/src/binary/binary_writer_impl.dart
 2int writeFrame(Frame frame, {HiveCipher? cipher}) {
 3    ArgumentError.checkNotNull(frame);
 4
 5    var startOffset = _offset;
 6    _reserveBytes(4);
 7    _offset += 4; // reserve bytes for length
 8
 9    writeKey(frame.key);
10
11    if (!frame.deleted) {
12        if (cipher == null) {
13            write(frame.value);
14        } else {
15            writeEncrypted(frame.value, cipher);
16        }
17    }
18
19    var frameLength = _offset - startOffset + 4;
20    _buffer.writeUint32(startOffset, frameLength);
21
22    var crc = Crc32.compute(
23        _buffer,
24        offset: startOffset,
25        length: frameLength - 4,
26        crc: cipher?.calculateKeyCrc() ?? 0,
27    );
28    writeUint32(crc);
29
30    return frameLength;
31}
  1. 타입을 찾은 뒤 writeTypeId 여부에 따라 typeId 1byte를 쓰고, 커스텀 TypeAdapter가 있다면 어댑터의 write를 호출하면서 나머지 데이터를 쓴다.
 1// hive/lib/src/binary/binary_writer_impl.dart
 2@override
 3void write<T>(T value, {bool writeTypeId = true}) {
 4    if (value == null) {
 5        if (writeTypeId) {
 6            writeByte(FrameValueType.nullT);
 7        }
 8    } else {
 9        // 등록한 어댑터가 있는지 확인
10        var resolved = _typeRegistry.findAdapterForValue(value);
11        if (resolved == null) {
12            throw HiveError('Cannot write, unknown type: ${value.runtimeType}. '
13                'Did you forget to register an adapter?');
14        }
15        if (writeTypeId) {
16            writeByte(resolved.typeId);
17        }
18        resolved.adapter.write(this, value);
19    }
20}
  1. 다시 개발자가 작성한 로직(TypeAdapter)으로 돌아와 타입에 맞게 데이터를 쓴다.
    멤버를 파일에 써야하기 때문에 BinaryWirter의 write를 호출하면서 타입 체크부터 반복해서 진행한다.
 1// lib/address.g.dart
 2class AddressAdapter extends TypeAdapter<Address> {
 3  @override
 4  final int typeId = 1;
 5
 6  @override
 7  void write(BinaryWriter writer, Address obj) {
 8    writer
 9      ..writeByte(3)
10      ..writeByte(0)
11      ..write(obj.street)
12      ..writeByte(1)
13      ..write(obj.city)
14      ..writeByte(2)
15      ..write(obj.country);
16  }
17}

결국 put을 하면 Hive가 등록된 타입을 찾아 typeId를 버퍼에 쓴 뒤, 이후 개발자가 정의한대로 TypeAdapter 로직에서 바이너리 데이터를 쓴다.


파일을 읽는 로직 #

  1. 파일을 읽는건 get에서가 아니라 open에서 발생한다.
1// lib/main.dart
2var box = await Hive.openBox('person');
3await box.put('person', Person('Alice', 25));
4Person person = box.get('person'); // Person(name: Alice, age: 25)
  1. open 할때 박스를 초기화 한 다음 집어넣는다.
 1// hive/lib/src/hive_impl.dart
 2Future<BoxBase<E>> _openBox<E>(
 3    String name, ...
 4) async {
 5    ...
 6    StorageBackend backend;
 7    if (bytes != null) {
 8        backend = StorageBackendMemory(bytes, cipher);
 9    } else {
10        backend = await _manager.open(
11        name, path ?? homePath, recovery, cipher, collection);
12    }
13
14    newBox = BoxImpl<E>(this, name, comparator, compaction, backend);
15
16    await newBox.initialize();
17    _boxes[name] = newBox;
18    completer.complete();
19    return newBox;
20}
21
22// hive/lib/src/box/box_base_impl.dart
23Future<void> initialize() {
24    return backend.initialize(hive, keystore, lazy);
25}
  1. 백엔드의 initailize 함수에서 BinaryReader의 readFrame을 호출한다.
 1// hive/lib/src/backend/vm/storage_backend_vm.dart
 2Future<void> initialize(TypeRegistry registry, Keystore keystore, bool lazy) async {
 3    ...
 4    await _frameHelper.framesFromFile(path, keystore, registry, _cipher);
 5    ...
 6}
 7
 8// hive/lib/src/binary/frame_helper.dart
 9class FrameHelper {
10  int framesFromBytes(Uint8List bytes, Keystore? keystore, TypeRegistry registry, HiveCipher? cipher) {
11    var reader = BinaryReaderImpl(bytes, registry);
12
13    while (reader.availableBytes != 0) {
14      var frameOffset = reader.usedBytes;
15      var frame = reader.readFrame(
16        cipher: cipher,
17        lazy: false,
18        frameOffset: frameOffset,
19      );
20      if (frame == null) return frameOffset;
21      keystore!.insert(frame, notify: false);
22    }
23    return -1;
24  }
25}
  1. 하나의 box를 가져오는 함수이다.
    write와 마찬가지로 typeId가 저장된 바이트를 읽고 알려진 타입이면 해당하는 바이트 읽기 함수를 실행하고 매칭되지 않으면 타입어댑터를 가져와서 read를 실행시킨다.
 1// hive/lib/src/binary/binary_reader_impl.dart
 2Frame? readFrame(...) {
 3    var frameLength = readUint32();   // BoxSize
 4
 5    // CRC를 가져와서 검사
 6    var crc = _buffer.readUint32(_offset + frameLength - 8);
 7    var computedCrc = Crc32.compute( _buffer, ... );
 8    ... 
 9    dynamic key = readKey();
10
11    if (availableBytes == 0) {
12      frame = Frame.deleted(key);
13    } else if (lazy) {
14      frame = Frame.lazy(key);
15    } else if (cipher == null) {
16      frame = Frame(key, read());    // 여기에서 데이터를 읽어온다. 
17    } else {
18      frame = Frame(key, readEncrypted(cipher));
19    }
20    ...
21    skip(4); // Skip CRC
22
23    return frame;
24}
25
26@override
27dynamic read([int? typeId]) {
28    typeId ??= readByte();
29    switch (typeId) {
30      case FrameValueType.nullT:
31        return null;
32        ...
33      default:
34        var resolved = _typeRegistry.findAdapterForTypeId(typeId);
35        if (resolved == null) {
36          throw HiveError('Cannot read, unknown typeId: $typeId. '
37              'Did you forget to register an adapter?');
38        }
39        return resolved.adapter.read(this);
40    }
41}
  1. 개발자의 타입 어댑터에 해당하는 read 함수를 호출한다.
    커스텀 타입 안에서도 멤버 타입에 따라 BinaryReader의 read 함수를 호출한다.
 1// lib/mytype.g.dart
 2class MyTypeAdapter extends TypeAdapter<MyType> { 
 3    @override 
 4    MyType read(BinaryReader reader) {
 5        final field1 = reader.readInt();
 6        final field2 = reader.readString(); 
 7        return MyType(field1, field2); 
 8    }
 9	@override 
10    void write(BinaryWriter writer, MyType obj) {
11		writer.writeInt(obj.field1); 
12        writer.writeString(obj.field2);
13	} 
14}

바이너리 파일 분석하기 #

파일 구조 #

전체적으론 한 프레임(Box)안에, size로 시작해서 키-값 쌍이 저장된 후 CRC 값까지 포함된다. 02fb35ea-a97a-4a0a-94ee-afc295975426

커스텀 타입도 보통은 아래와 같다. 키(STRING)-값(BEE) 이 한 프레임 안에 저장되어 있고, 끝에 CRC 값이 있다.
커스텀 타입은 내부적으로 각 데이터를 필드 인덱스로 구분한다.

보통은 이라고 말한 이유는 커스텀 타입은 사실 타입 어댑터로 읽기 때문에 명령어로 자동생성한 타입 어댑터가 아니라면 앱을 빌드한 개발자의 마음에 따라 데이터가 달라질 수 있다. e252f617-7f8b-465a-bf3a-3dfa3d3ac19a


기본 타입 #

각 타입마다 typeId 값이 있으며 파일 안에서 typeId + data 구조인데 data 는 어떤 타입이냐에 따라 읽는 방법이 다르다.

키와 밸류가 같은 타입이여도 typeId 값이 다르다.

 1// hive/lib/src/binary/frame.dart
 2class FrameKeyType {
 3  static const uintT = 0;          // Integer key
 4  static const utf8StringT = 1;    // String key
 5}
 6
 7class FrameValueType {
 8  static const nullT = 0;          // null
 9  static const intT = 1;           // int
10  static const doubleT = 2;        // double
11  static const boolT = 3;          // bool
12  static const stringT = 4;        // String
13  static const byteListT = 5;      // Uint8List
14  static const intListT = 6;       // List<int>
15  static const doubleListT = 7;    // List<double>
16  static const boolListT = 8;      // List<bool>
17  static const stringListT = 9;    // List<String>
18  static const listT = 10;         // List<dynamic>
19  static const mapT = 11;          // Map<dynamic, dynamic>
20  static const hiveListT = 12;     // List<HiveObject>
21}
22
23// hive/lib/src/binary/binary_reader_impl.dart
24  @override
25  int readInt() {
26    return readDouble().toInt();
27  }
28
29  @override
30  double readDouble() {
31    _requireBytes(8);
32    var value = _byteData.getFloat64(_offset, Endian.little);
33    _offset += 8;
34    return value;
35  }

커스텀 타입 분석하기 #

커스텀 타입은 개발자가 구현한 타입이기 때문에 빌드된 앱을 분석해야 한다.
앱이 릴리즈 모드로 빌드되면 dart 코드는 네이티브 라이브러리로 빌드되며 libapp.so 이름으로 앱에 들어간다. 그래픽UI 등을 담당하는 libflutter.so는 플러터 엔진이다.

커스텀 typeId 확인 방법 #

blutter 라는 flutter 리버싱 툴을 돌리고 나면 libapp.so 파일을 dart에 맞게 배치시켜주며, registerAdapter() 로 타입을 등록하는 곳에서 발견할 수 있다.

아래 이미지에서 registerAdapter를 호출하기 위해 DescriptionAdapter 를 생성하는 부분이 있는데 갑자기 어떤 값을 movz로 x0에 넣는것을 볼 수 있다.

이 값이 typeId 이고, 여기에 reservedTypeIds 값을 더해 실제 파일에 저장되는 typeId가 된다.

033c0712-3af3-40a8-9169-fb6a62fec2e8


커스텀 타입 어댑터 분석 #

blutter로 분석된 코드에서 원하는 custom.dart 파일에 커스텀 어댑터의 코드들(read, write, constructor, ==)을 볼 수 있다.

커스텀 타입의 필드 수 저장 #

typeId는 이미 Hive 코드에서 이미 저장됐기 때문에 typeAdapter에서는 가장먼저 하는 일이 필드 수를 저장하는 것이다.
write 함수의 맨 처음 _increaseBufferSize 함수 호출 이후 movz로 어떤 값을 x2에 가져오고, 버퍼 인덱스 계산 후 strb 로 w2의 1byte만 넣는 것을 볼 수 있다.

82e0838e-2058-4d25-a7bd-2ccd170136df

각 필드 저장 #

write 함수에서 계속해서 BinaryWriterImpl::write 를 검색 후 따라가다보면 어떤 타입이 저장되는지 보인다. 당연히 값은 런타임에 정해질테니 정적 분석에서는 확인할 수 없다. 필드 길이를 저장하는 것과 같이 _increaseBufferSize → write 를 반복한다.

d6cdd7fd-1715-4dd1-b682-bc2e64d8b1fd

실제 필드를 저장하는 한 세트를 한번 분석해보자. 대략적인 원본 코드

 1void PersonAdapter::write(BinaryWriter writer, Person obj) {    // [fp, #0x18], [fp, #0x10]
 2  writer
 3    ..writeByte(16)
 4    // ... 생략
 5    ..writeByte(1)      // <--- 어셈 시작
 6    ..write(obj.name)   // <--- 어셈 끝 
 7    ..writeByte(2)
 8    ..write(obj.age)
 9    // ... 생략 
10}

원본 코드에서 ..writeByte(1); ..write(obj.name); 만 어셈블리로 가져옴

 1// 필드 오프셋을 쓰기 위해 버퍼 크기 증가
 2bl              #0x76d12c  ; [package:hive/src/binary/binary_writer_impl.dart] BinaryWriterImpl::_increaseBufferSize
 3// 스택 프레임에서 값을 x2, x4에 로드. x2는 데이터를 저장할 쓰기버퍼고, x4는 write 대상 오브젝트(obj) 이다. 
 4ldr             x2, [fp, #0x18]
 5ldr             x4, [fp, #0x10]
 6movz            x3, #0x1    ; 2번째 필드라서 0x1이다. 
 7
 8// w5에 x2 + 0xb 필드의 값을 로드. 
 9ldur            w5, [x2, #0xb]
10// x5 = x5 + (HEAP << 32) 인데, HEAP의 압축 해제(HEAP 베이스주소가 됨) 후 x5를 더함
11add             x5, x5, HEAP, lsl #32
12
13// x2 + 0x13 필드의 값을 1 증가 (버퍼의 size 변수를 1 증가시킴)
14ldur            x6, [x2, #0x13]
15add             x0, x6, #1
16stur            x0, [x2, #0x13]
17
18// 버퍼의 크기를 비교해서 충분한지 확인한다. 
19// x5 + 0x13 필드의 값을 가져와서 힙의 베이스와 더함
20ldur            w0, [x5, #0x13]
21add             x0, x0, HEAP, lsl #32
22// x0에서 1번째부터 1f까지 31bit만 가져와서 x1에 넣는다.
23sbfx            x1, x0, #1, #0x1f
24// x1과 x6 (위에서 1 증가한 버퍼사이즈) 의 비교
25mov             x0, x1
26mov             x1, x6
27cmp             x1, x0
28b.hs            #0xa9c8a8
29
30// 버퍼 오프셋 변경(x0 = buf + size) 후 타입 저장 (x3=0x1=w3)
31add             x0, x5, x6
32strb            w3, [x0, #0x17]
33
34// x4의 0xb 위치에서 필드값을 가져와 w0에 저장한다. (obj.name 을 의미한다.)
35ldur            w0, [x4, #0xb]
36add             x0, x0, HEAP, lsl #32
37
38// [x0:obj.name][x2:Buffer][x16:<String>Type]
39// String 타입 로드해서 스택에 저장, 
40ldr             x16, [PP, #0x420]  ; [pp+0x420] TypeArguments: <String>
41stp             x2, x16, [SP, #8]
42// 아까 가져온 필드 (obj.name)를 스택에 저장
43str             x0, [SP]
44ldr             x4, [PP, #0x58]  ; [pp+0x58] List(5) [0x1, 0x2, 0x2, 0x2, Null]
45// write 함수 호출 
46bl              #0x769af8  ; [package:hive/src/binary/binary_writer_impl.dart] BinaryWriterImpl::write
47
48// 버퍼 상태 업데이트
49ldr             x0, [fp, #0x18]
50ldur            w1, [x0, #0xb]
51add             x1, x1, HEAP, lsl #32
52ldur            w2, [x1, #0x13]
53add             x2, x2, HEAP, lsl #32
54ldur            x1, [x0, #0x13]
55sbfx            x3, x2, #1, #0x1f
56sub             x2, x3, x1
57cmp             x2, #1
58b.ge            #0xa9c3dc
59movz            x1, #0x1
60stp             x1, x0, [SP]
61
62// 두번째 쓰기작업 시작. 
63// 다시 x2는 버퍼, x4는 obj 부터 시작 
64bl              #0x76d12c  ; BinaryWriterImpl::_increaseBufferSize
65ldr             x2, [fp, #0x18]
66ldr             x4, [fp, #0x10]

처음에 쓰기를 시작할때, 최상단 함수의 인자로 전달된 버퍼와 객체를 스택 포인터의 상대주소 접근으로 가져온 후
내부에서 버퍼를 하나씩 늘려가며 size 값 쓰기 → 객체에서 필드접근 → 필드 타입 + 필드값 쓰기 순서로 진행한다.

c 구조체처럼 int는 8byte를 차지하고, String은 pointer로 4byte를 차지하는 등 각 필드마다 길이가 달라지기 때문에 클래스 안에서 타입에 맞게 필드 오프셋을 생각해야 한다.

그리고 필드오프셋을 알아온다 하더라도 필드명과 매핑시키는 작업은 개발자가 toString을 구현하지 않고서는 어렵다.

만약 toString이 있다면 #

모든 toString이 이런식으로 구현되진 않겠지만 this를 stack에서 로드하는 곳을 확인하고, 오프셋으로 어떤 필드를 접근하는지 확인하면 된다.

 1add             x17, PP, #0x12, lsl #12  ; [pp+0x12da8] "SharingFile.contentProvider(status: "
 2
 3ldr             x17, [x17, #0xda8]
 4stur            w17, [x2, #0xf]
 5ldr             x3, [fp, #0x10]     // this 로드 
 6ldur            w0, [x3, #7]        // 0x07 필드 접근
 7add             x0, x0, HEAP, lsl #32
 8stur            w0, [x2, #0x13]
 9add             x17, PP, #0x11, lsl #12  ; [pp+0x114b0] ", name: "
10
11ldr             x17, [x17, #0x4b0]
12stur            w17, [x2, #0x17]
13ldur            w0, [x3, #0xb]      // 0x0b 필드 접근. x3은 위에서 한번 로드하고 변하지 않음
14add             x0, x0, HEAP, lsl #32
15stur            w0, [x2, #0x1b]
16add             x17, PP, #0x12, lsl #12  ; [pp+0x12d30] ", mimeType: "

저장되는 타입의 순서 확인2 #

write에서도 어차피 increase 하면서 다음 인덱스에 저장하는 방식이라 순서가 맞을것이다. 항상은 아니지만, 일부 앱에서는 커스텀 타입의 생성자에서 멤버들이 SetupParameters 안에서 보일때가 있다.

 1_ History(/* No info */) {
 2    // ** addr: 0x8f3958, size: 0xab8
 3    // 0x8f3958: EnterFrame
 4    //     0x8f3958: stp             fp, lr, [SP, #-0x10]!
 5    //     0x8f395c: mov             fp, SP
 6    // 0x8f3960: AllocStack(0xb0)
 7    //     0x8f3960: sub             SP, SP, #0xb0
 8    // 0x8f3964: SetupParameters(History this /* r3, fp-0x98 */, dynamic _ /* fp-0x8 */, {dynamic description = Null /* r5, fp-0x90 */, dynamic dueDate = "" /* r6, fp-0x88 */, dynamic gtsInfo = Null /* r7, fp-0x80 */, dynamic isNotReady = false /* r8, fp-0x78 */, dynamic isOnlyMe = false /* r9, fp-0x70 */, dynamic password = Null /* r10, fp-0x68 */, dynamic pin = Instance__$PinNone /* r11, fp-0x60 */, dynamic roomId = Null /* r12, fp-0x58 */, dynamic sharedText = Null /* r13, fp-0x50 */, dynamic sharingFilesLegacy = const [] /* r14, fp-0x48 */, dynamic side = Instance_SharingSide /* r19, fp-0x40 */, dynamic startDate = "" /* r20, fp-0x38 */, dynamic status = Instance_SharingStatus /* fp-0x10 */, dynamic thumbnail = "" /* fp-0x18 */, dynamic totalCnt = Null /* fp-0x20 */, dynamic totalSize = Null /* r4, fp-0x30 */, dynamic userId = "" /* r2, fp-0x28 */})
 9    //     0x8f3964: mov             x0, x4
10    ...

그리고 read 함수에서 해당 생성자를 호출 하면서 데이터를 담는 곳을 확인하면 순서를 확인할 수 있다.

구조는 [0, 0x13, 0x13, 0x2, typename, index+2, typename2, index2+2, …, Null] 형식이다.
아래 예시에서 userId는 0번째 인덱스이다.

1    // 0xaac128: r4 = const [0, 0x13, 0x13, 0x2, description, 0x12, dueDate, 0x7, gtsInfo, 0xa, isNotReady, 0x10, isOnlyMe, 0xf, password, 0xb, pin, 0x4, roomId, 0x11, sharedText, 0xe, sharingFilesLegacy, 0x9, side, 0x3, startDate, 0x6, status, 0x8, thumbnail, 0x5, totalCnt, 0xc, totalSize, 0xd, userId, 0x2, null]
2    //     0xaac128: add             x4, PP, #0x1a, lsl #12  ; [pp+0x1a8c0] List(39) [0, 0x13, 0x13, 0x2, "description", 0x12, "dueDate", 0x7, "gtsInfo", 0xa, "isNotReady", 0x10, "isOnlyMe", 0xf, "password", 0xb, "pin", 0x4, "roomId", 0x11, "sharedText", 0xe, "sharingFilesLegacy", 0x9, "side", 0x3, "startDate", 0x6, "status", 0x8, "thumbnail", 0x5, "totalCnt", 0xc, "totalSize", 0xd, "userId", 0x2, Null]
3    //     0xaac12c: ldr             x4, [x4, #0x8c0]
4    // 0xaac130: r0 = History()

물론 멤버 이름이 전부 사라지는 경우도 있다.


커스텀 enum 타입 #

enum도 동일하게 커스텀 타입이고, 타입 아이디를 갖게된다.

 1@HiveType(typeId: 5) // 새로운 타입 ID
 2enum Gender {
 3  @HiveField(0)
 4  male,
 5  @HiveField(1)
 6  female,
 7  @HiveField(2)
 8  other,
 9  @HiveField(3)
10  good,
11  @HiveField(4)
12  hoho,
13}

blutter를 사용하면 추출되는 objs.txt 파일에서 어떤 정수값이 어떤 문자열과 연결되는지 확인할 수 있다.

 1Obj!Gender@416601 : {
 2  Super!_Enum : {
 3    off_8: int(0x4),
 4    off_10: "hoho"
 5  }
 6}
 7
 8Obj!Gender@416621 : {
 9  Super!_Enum : {
10    off_8: int(0x3),
11    off_10: "good"
12  }
13}
14...

실제로 데이터가 저장될땐 typeId + 1byte 로 저장된다.

 1  @override
 2  void write(BinaryWriter writer, Gender obj) {
 3    switch (obj) {
 4      case Gender.male:
 5        writer.writeByte(0);
 6        break;
 7      case Gender.female:
 8        writer.writeByte(1);
 9        break;
10      case Gender.other:
11        writer.writeByte(2);
12        break;
13      case Gender.good:
14        writer.writeByte(3);
15        break;
16      case Gender.hoho:
17        writer.writeByte(4);
18        break;
19    }
20  }

Person 타입에서 맨 마지막 멤버가 Gender(typeid: 5)=0x25 이고, 1이 저장된 것으로 봐서 Gender.female 임을 알 수 있다. 525911bc-ce32-438b-8fde-1480a76434b6


String이나 int 타입도 enum이 가능하다. #

 1@HiveType(typeId: 10)
 2enum Status {
 3  @HiveField(0)
 4  active('ACTIVE'),
 5  @HiveField(1)
 6  inactive('INACTIVE');
 7
 8  final String value;
 9  
10  const Status(this.value);
11
12  static Status fromString(String value) {
13    return Status.values.firstWhere((e) => e.value == value, orElse: () => Status.inactive);
14  }
15}

만들어진 어댑터를 보면, 보통의 enum과 동일하게 1 byte만 저장된다.

 1class StatusAdapter extends TypeAdapter<Status> {
 2  @override
 3  void write(BinaryWriter writer, Status obj) {
 4    switch (obj) {
 5      case Status.active:
 6        writer.writeByte(0);
 7        break;
 8      case Status.inactive:
 9        writer.writeByte(1);
10        break;
11    }
12  }
13  // ...
14}

제너레이터가 생성하는 구조와 다를 때 #

플러터 버전에 따라 제너레이터의 동작이 변경된 것인지, 제너레이터를 사용하지 않고 직접 어댑터를 작성해서인지 알 수 없지만 커스텀 아이디를 가지고있는 필드가 사이즈와 오프셋이 바이너리에 포함되지 않는 경우도 있었다.

어차피 어댑터 코드는 빌드돼서 앱의 네이티브 코드에 포함되고, 빌드된 해당 앱에서만 읽고 쓰기가 가능하면 되기 때문에 위에서 분석한 데이터 구조가 필수적인 부분은 아니다. 그렇기 때문에 아래 코드처럼 어댑터를 직접 수정해서 원하는 바이너리 box 파일 구조를 만들 수 있다.

 1// description.dart
 2@HiveType(typeId: 2) // 고유 ID
 3class Description {
 4  @HiveField(0)
 5  int value; // 1 byte 크기 정수 (0~255)
 6
 7  Description(this.value);
 8}
 9
10// description.g.dart
11class DescriptionAdapter extends TypeAdapter<Description> {
12  @override
13  final int typeId = 2;
14
15  @override
16  Description read(BinaryReader reader) {
17    final fields = reader.readInt();
18    return Description(
19      fields,
20    );
21  }
22
23  @override
24  void write(BinaryWriter writer, Description obj) {
25    writer
26      .writeInt(obj.value);
27  }
28  // ...
29}

이렇게 작성하면 커스텀 타입 이후로 필드 길이와 오프셋이 파일에 저장되지 않는다.

fieldId : 0xC (12)
typeId : 0x22
typeVal : 0x4047800000000000 (47)

1e0cc6a4-5a5d-4693-958c-dd504f9b6056

0xC 이후 typeId 0x22 값이 있고, 거기에 맞는 value가 float 으로 저장된다.

953cb889-4837-43b5-bd0c-ce85f039386e

comments powered by Disqus