훅(Hooks)

훅(Hooks)

훅(hook)은 Dart 패키지가 네이티브 에셋(다른 언어로 작성돼 기계어로 컴파일되는 코드)을 컴파일하거나 다운로드해서, 그 에셋을 패키지의 Dart 코드에서 호출할 수 있게 해 주는 메커니즘이에요. 이 가이드는 훅이 무엇인지, 패키지에서 어떻게 쓰는지 설명해요.

출처: Hooks

본문

소개

현재 훅으로 할 수 있는 일에는, 네이티브 에셋(다른 언어로 작성돼 기계어로 컴파일되는 코드)을 컴파일하거나 다운로드한 뒤, 그 에셋을 패키지의 Dart 코드에서 호출하는 것 같은 것들이 있어요.

훅은 Dart 패키지의 hook/ 디렉토리에 두는 Dart 스크립트예요. 이들은 입력과 출력 형식이 미리 정해져 있어서, Dart SDK가 다음을 할 수 있어요.

  • 훅을 찾는다.
  • 필요한 입력으로 훅을 실행한다.
  • 훅이 만든 출력을 소비한다.

빌드 훅이 있는 예제 프로젝트 구조예요.

example_project  // 훅이 있는 프로젝트
hook/            // 훅 스크립트를 여기에 둔다
build.dart
lib/             // 여기서 에셋을 사용한다
example.dart
src/             // 네이티브 소스를 여기에 둔다
example_native_library.c
example_native_library.h
test/            // 여기서 에셋을 테스트한다
example_test.dart

현재 빌드 훅(build hook)과 링크 훅(link hook)이 제공돼요. 자세한 내용은 아래를 참고해요.

빌드 훅

빌드 훅으로 패키지는 C나 Rust 라이브러리 같은 네이티브 에셋을 컴파일하거나 다운로드할 수 있어요. 그런 다음 그 에셋을 패키지의 Dart 코드에서 호출할 수 있죠.

패키지의 빌드 훅은 빌드 과정 중 적절한 시점에 Dart SDK가 자동으로 호출해요. 빌드 훅은 Dart 컴파일과 병렬로 실행되며, 다운로드나 네이티브 컴파일러 호출처럼 오래 걸리는 작업을 할 수 있어요.

build 함수로 BuildInput을 사용해 훅 입력을 분석하고, BuildOutputBuilder로 훅 출력을 작성하면 돼요. 훅은 다운로드·생성한 에셋을 BuildInput.sharedOutputDirectory에 두어야 해요.

패키지용으로 만들어진 에셋은 pubspec에서 직접 의존성(direct dependencies)으로 갖는 패키지들의 빌드 훅이 만든 assetsmetadata에 의존할 수 있어요. 그래서 빌드 훅은 pubspec의 의존성 순서대로 실행되며, 훅을 쓸 때 패키지 간 순환 의존성은 지원되지 않아요.

링크 훅

링크 훅으로 패키지는 빌드 훅이 생성한 코드 에셋을 애플리케이션에 번들링하기 전에 필터링·최적화·tree-shaking할 수 있어요.

Dart SDK는 애플리케이션 번들링 단계에서 패키지의 링크 훅(hook/link.dart)을 자동으로 호출해요. 각 패키지마다 실행되는 빌드 훅과 달리, 링크 훅은 애플리케이션 수준의 컨텍스트로 실행돼서 애플리케이션이 실제로 어떤 심볼을 쓰는지 검사할 수 있어요.

link 함수로 LinkInput을 사용해 훅 입력을 분석하고, LinkOutputBuilder로 훅 출력을 작성하면 돼요. tree-shaking이 어떤 에셋의 모든 심볼을 제거하면, 링크 훅은 그 에셋을 아예 생략해 애플리케이션과 함께 번들링되지 않게 할 수 있어요.

환경 변수

훅은 반-격리(semi-hermetic) 환경에서 실행돼요. 즉 Platform.environment가 부모 프로세스의 모든 환경 변수를 노출하지 않는다는 뜻이에요. 이렇게 해서 훅 호출을 재현 가능하고 캐시 가능하게 만들고, 우연한 환경 변수에 의존하지 않게 해 줘요.

다만 일부 환경 변수는 도구(컴파일러 등)를 찾거나 네트워크 접근을 구성하는 데 필요해요. 다음 환경 변수들은 훅 프로세스에 전달돼요.

  • 경로와 시스템 루트

    • PATH: 네이티브 도구를 호출한다.
    • HOME, USERPROFILE: 기본 설치 위치에서 도구를 찾는다.
    • SYSTEMDRIVE, SYSTEMROOT, WINDIR: Windows에서 프로세스 호출과 CMake에 사용.
    • PROGRAMDATA: Windows의 vswhere.exe용.
  • 임시 디렉토리

    • TEMP, TMP, TMPDIR: 임시 디렉토리.
  • HTTP 프록시

    • HTTP_PROXY, HTTPS_PROXY, NO_PROXY: 프록시 뒤에서의 네트워크 접근.
  • Clang/LLVM

    • LIBCLANG_PATH: Rust의 bindgen + clang-sys.
  • Android NDK

    • ANDROID_HOME: Android SDK/NDK의 표준 위치.
    • ANDROID_NDK, ANDROID_NDK_HOME, ANDROID_NDK_LATEST_HOME, ANDROID_NDK_ROOT: NDK의 대체 위치.
  • Nix

    • NIX_로 시작하는 모든 변수.

이 환경 변수들에 대한 변경은 훅의 캐시 무효화를 일으켜요. 그 외의 모든 환경 변수는 제거돼요.

에셋

에셋은 훅이 만들어낸 파일로서 Dart 애플리케이션에 번들링되는 파일이에요. 에셋은 런타임에 Dart 코드에서 접근할 수 있어요. 현재 Dart SDK는 CodeAsset 타입을 쓸 수 있고, 더 많은 에셋 타입이 계획돼 있어요.

CodeAsset 타입

CodeAsset은 코드 에셋을 나타내요. 코드 에셋은 Dart가 아닌 다른 언어(C, C++, Rust, Go 등)로 컴파일된 동적 라이브러리예요. CodeAssetcode_asset 패키지의 일부예요. 코드 에셋이 제공하는 API는 런타임에 dart:ffi@Native 어노테이션으로 표시된 해당 external Dart 멤버를 통해 접근해요.

훅 사용하기

프로젝트에 에셋을 추가하려면 훅을 쓰면 돼요. 자세한 내용은 다음 섹션들에서 볼게요.

의존성 추가

훅을 쓰려면 먼저 헬퍼 패키지 hookscode_assetspubspec.yaml의 dependencies에 추가해야 해요.

dart pub add hooks code_assets

C 소스를 컴파일해야 한다면 native_toolchain_c 패키지도 필요해요.

dart pub add native_toolchain_c

📌 참고: 의존성은 dev_dependencies가 아니라 dependencies 아래에 추가해야 해요. 훅은 여러분의 패키지에 의존하는 패키지와 애플리케이션에 의해 실행되므로, Dart 코드가 그 패키지들의 해석(resolution)에 포함돼야 하기 때문이에요.

빌드 훅을 위한 예제 의존성:

name: native_add_library
description: Sums two numbers with native code.
version: 0.1.0

environment:
  sdk: '^3.10.0'

dependencies:
  # ...
  code_assets: any
  hooks: any
  native_toolchain_c: any

dev_dependencies:
  # ...
  ffigen: ^18.0.0

네이티브 에셋을 생성하는 빌드 훅 만들기

C나 Rust 라이브러리 같은 네이티브 에셋을 투명하게 컴파일해서, 그 에셋을 패키지의 Dart 코드에서 호출할 수 있게 하고 싶다면, 다음과 같은 build.dart 스크립트를 만들면 돼요.

  1. Dart 프로젝트에서 hook/build.dart를 만들거나 열어요.
  2. main 메서드에서 package:hooks/hooks.dartbuild 함수를 호출하고, 적절한 툴체인으로 네이티브 라이브러리를 컴파일해요. 예를 들면:
import 'package:hooks/hooks.dart';
import 'package:native_toolchain_c/native_toolchain_c.dart';

void main(List<String> args) async {
  await build(args, (input, output) async {
    final packageName = input.packageName;
    final cLibrary = CLibrary(
      name: packageName,
      assetName: '$packageName.dart',
      sources: ['src/$packageName.c'],
    );
    await cLibrary.build(
      input: input,
      output: output,
    );
  });
}

build의 두 번째 매개변수는 두 인자를 넘겨받는 함수를 기대해요.

  • input: 훅의 읽기 전용 입력이에요. 훅이 올바른 에셋 타입(예: 대상 OS, 대상 아키텍처, 출력 디렉토리 등)을 만들기 위한 정보가 포함돼요. 자세한 내용은 BuildInput 클래스를 참고해요.
  • output: 훅 출력을 위한 쓰기 전용 빌더예요. 빌드 훅이 입력을 읽은 뒤 에셋을 만들고, 만든 것을 출력으로 제공해요. 자세한 내용은 BuildOutputBuilder 클래스를 참고해요.

📌 참고: 동적 라이브러리 이름을 지을 때는 모든 대상 아키텍처와 SDK에서 이름이 일관돼야 해요. Apple 플랫폼에서는 Flutter의 빌드 시스템이 일관된 파일명에 의존해서 프레임워크와 XCFramework를 올바르게 생성해요. 더 자세한 내용은 Flutter 문서 사이트의 Binding to native code를 참고해요.

네이티브 에셋을 tree-shake하는 링크 훅 만들기

📌 버전 참고: 링크 훅과 recorded usage tree-shaking 지원은 Dart 3.13에서 도입됐어요.

패키지가 빌드 훅(hook/build.dart)으로 네이티브 에셋을 생성한다면, 번들링 전에 쓰이지 않는 코드 에셋을 tree-shake하기 위해 링크 훅(hook/link.dart)을 추가할 수 있어요.

컴파일 중에 Dart 컴파일러는 애플리케이션이 실제로 참조하는 @Native 심볼을 기록하고, 이를 LinkInput.recordedUses를 통해 링크 훅에 제공해요. input.recordedUsesnull이면 링크 훅은 tree-shaking을 끄고 모든 심볼을 유지해요.

  1. Dart 프로젝트에서 hook/link.dart를 만들거나 열어요.
  2. main 메서드에서 package:hooks/hooks.dartlink 함수를 호출하고 CLibrary.link에 tree-shaking 옵션을 넘겨요. 예를 들면:
import 'package:hooks/hooks.dart';
import 'package:native_toolchain_c/native_toolchain_c.dart';
import 'package:record_use/record_use.dart';

import 'record_use_mapping.dart';

void main(List<String> args) async {
  await link(args, (input, output) async {
    final packageName = input.packageName;
    final cLibrary = CLibrary(
      name: packageName,
      assetName: '$packageName.dart',
      sources: ['src/$packageName.c'],
    );

    final linkerOptions = LinkerOptions.treeshake(
      symbolsToKeep: input.recordedUses?.calls.keys
          .cast<Method>()
          .map((e) => recordUseMapping[e.name]!),
    );

    await cLibrary.link(
      input: input,
      output: output,
      linkerOptions: linkerOptions,
    );
  });
}

ffigen 같은 도구로 생성한 바인딩의 Dart 심볼이 항상 네이티브 C 심볼 이름과 일대일로 대응하지는 않아요. FFI 바인딩을 생성할 때 ffigen은 Dart 메서드 식별자를 네이티브 심볼로 매핑한 recordUseMapping을 자동 생성할 수 있어요. 기록된 각 Dart 메서드 호출(input.recordedUses?.calls.keys)을 recordUseMapping으로 매핑해서 대응하는 네이티브 심볼을 LinkerOptions.treeshake에 넘겨 주세요.

symbolsToKeepnull이면(예: input.recordedUsesnull일 때), LinkerOptions.treeshake는 네이티브 라이브러리의 모든 심볼을 유지해요. symbolsToKeep가 빈 목록([]), 즉 애플리케이션이 그 라이브러리의 어떤 심볼도 참조하지 않는다면, CLibrary.link는 동적 라이브러리를 컴파일하고 번들링하는 것을 자동으로 건너뛰어요.

자동 번들링되는 에셋

훅은 run, build, test 명령을 호출할 때 자동으로 실행돼요. 결과로 만들어진 에셋은 훅 입력에 지정된 출력 디렉토리에 저장돼요. 그런 다음 Dart SDK가 그 에셋들을 Dart 앱에 자동으로 번들링해서 런타임에 접근할 수 있게 해 줘요.

에셋 사용하기

에셋은 훅이 만드는 파일이에요. 에셋이 만들어지면 코드와 런타임에서 그 에셋 ID(assetId)로 참조할 수 있어요. 에셋 ID는 package:<패키지 이름>/<에셋 이름> 형태로 구성돼요. 빌드 훅은 자기 패키지 안의 에셋만 출력할 수 있어요. 앞 예제의 빌드 훅에 있는 CLibrarypackageNameassetName을 기반으로 package:native_add_library/native_add_library.dart라는 에셋 ID를 출력해요.

💡 모범 사례: 코드 에셋을 사용하려는 위치의 Dart 라이브러리 URI를 에셋 ID로 쓰는 걸 권장해요. 패키지 이름으로 시작하는 아무 에셋 ID나 쓸 수 있지만, 라이브러리 URI를 쓰면 @Native 어노테이션에 assetId를 지정하지 않아도 Dart가 런타임에 에셋을 자동으로 연결할 수 있어요.

다음 예제는 native_add_library.c의 네이티브 C 함수 add에 바인딩하고 호출하는 방법을 보여줘요.

// my_package/lib/my_package.dart
import 'dart:ffi';

@Native<Int32 Function(Int32, Int32)>()
external int add(int a, int b);
// my_package/bin/my_package.dart
import 'package:my_package/my_package.dart';

void main() {
  print(add(24, 18));
}

@Native의 에셋 ID는 선택 사항이고 기본값은 라이브러리 URI예요. 위 예제에서 그 값은 package:native_add_library/native_add_library.dart인데, 이는 빌드 훅이 출력한 에셋 ID와 같아요. 그래서 Dart가 런타임에 참조되는 에셋을 빌드 과정에서 훅이 제공한 에셋과 연결할 수 있어요.

에셋 테스트하기

에셋을 생성하는 훅을 작성하고 그 에셋을 Dart 코드에서 사용했다면, 훅과 생성된 에셋이 기대대로 동작하는지 검증하는 테스트를 작성해 보는 걸 고려해 봐요.

다음 예제는 네이티브 C 함수 add를 참조하는 스크립트 native_add_library.dart에 대한 테스트예요.

// test/native_add_library_test.dart
import 'package:native_add_library/native_add_library.dart';
import 'package:test/test.dart';

void main() {
  test('invoke native function', () {
    expect(add(24, 18), 42);
  });
}

훅 구성

프로젝트의 빌드 환경에서 빌드·링크 훅에 사용자 지정 매개변수나 로컬 파일 경로를 전달할 수 있어요. 이 매개변수들은 pubspec.yaml 파일의 hooks 키 아래에서 구성해요. 현재 이 블록은 user_defines 옵션만 지원해요.

user-defines 구성

루트 패키지 pubspec.yaml 파일(워크스페이스를 쓴다면 워크스페이스 pubspec.yaml 파일)에서 사용자 지정 매개변수를 구성해요. user-defines를 구성할 수 있는 건 최종 사용자—즉 의존성을 소비하는 루트 앱이나 패키지의 작성자—뿐이에요. 의존성은 자체 기본 user-defines를 제공할 수 없어요.

user_defines 아래의 구성 값은 boolean, string, number, 중첩 map, list 같은 어떤 JSON 호환 타입이든 될 수 있어요. User-defines는 패키지별로 필터링돼요. my_package 안의 훅은 hooks.user_defines.my_package 아래에 구성된 키만 접근할 수 있어요. 다른 패키지의 user-defines에는 접근할 수 없어요.

다음은 my_package 패키지의 훅에 두 개의 user-defines를 전달하는 예시예요.

hooks:
  user_defines:
    my_package:
      enable_experimental: true
      custom_lib: assets/libnative.so

훅에서 user-defines 접근하기

build.dartlink.dart 훅 스크립트에서 구성한 user-defines에 접근하려면 input.userDefines 객체를 쓰면 돼요.

  • 대괄호 연산자로 원시 값을 읽어요. 예: input.userDefines['key'].
  • path() 메서드로 상대 경로를 Uri로 해석할 수도 있어요. 예: input.userDefines.path('key'). 이 메서드는 user-defines가 선언된 pubspec.yaml의 디렉토리를 기준으로 상대 경로를 해석해요.

훅이 해석된 파일이나 디렉토리를 읽는다면, output.dependencies.add()로 그걸 의존성으로 등록해 줘요. 이렇게 해야 파일이 바뀌었을 때 빌드 시스템이 캐시를 무효화하고 훅을 다시 실행해요.

enable_experimentalcustom_lib user-defines에 접근하는 예제 훅 스크립트예요.

// hook/build.dart
import 'dart:io';
import 'package:hooks/hooks.dart';

void main(List<String> args) async {
  await build(args, (input, output) async {
    final experimental = input.userDefines['enable_experimental'];
    if (experimental is! bool?) {
      throw const FormatException(
        'hooks.user_defines.my_package.enable_experimental must be a '
        'boolean (or omitted)',
      );
    }

    if (experimental ?? false) {
      print('Experimental features enabled.');
    }

    final customLibUri = input.userDefines.path('custom_lib');
    if (customLibUri != null) {
      final file = File.fromUri(customLibUri);
      output.dependencies.add(file.uri);
      // Use the file...
    }
  });
}

예제 프로젝트

훅과 코드 에셋을 시작하는 데 도움이 되는 여러 예제 프로젝트가 있어요.

프로젝트 설명
sqlite 네이티브 데이터베이스 엔진을 컴파일·번들링·tree-shaking·사용하는 패키지예요.
mini_audio 네이티브 오디오 플레이어를 컴파일·번들링·tree-shaking·사용하는 패키지예요.
stb_image 네이티브 이미지 라이브러리를 컴파일·번들링·tree-shaking·사용하는 패키지예요.
host_name 네이티브 시스템 라이브러리를 사용하는 패키지예요.
native_add_library 간단한 C 코드를 컴파일·번들링·사용하는 패키지예요.
native_add_app native_add_library에 의존하는 Dart CLI 애플리케이션이에요.
download_asset 빌드 훅에서 다운로드한 사전 빌드 에셋을 번들링·사용하는 패키지예요.
native_dynamic_linking 서로 의존하는 세 개의 네이티브 라이브러리를 컴파일·번들링·사용하는 패키지예요.
use_dart_api Dart VM의 C API를 사용하는 패키지예요.

더 보기

더 많은 정보는 다음 링크를 참고해요.

  • Hooks 패키지
  • Hooks 라이브러리 레퍼런스
  • Code assets 패키지
  • Code assets 라이브러리 레퍼런스
  • Record use 패키지
  • Record use 라이브러리 레퍼런스
  • C interop
  • Flutter 앱에서 네이티브 코드에 바인딩하기

더 알아보기