훅(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)으로 갖는 패키지들의 빌드 훅이 만든 assets나 metadata에 의존할 수 있어요. 그래서 빌드 훅은 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 등)로 컴파일된 동적 라이브러리예요. CodeAsset은 code_asset 패키지의 일부예요. 코드 에셋이 제공하는 API는 런타임에 dart:ffi의 @Native 어노테이션으로 표시된 해당 external Dart 멤버를 통해 접근해요.
훅 사용하기
프로젝트에 에셋을 추가하려면 훅을 쓰면 돼요. 자세한 내용은 다음 섹션들에서 볼게요.
의존성 추가
훅을 쓰려면 먼저 헬퍼 패키지 hooks와 code_assets를 pubspec.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 스크립트를 만들면 돼요.
- Dart 프로젝트에서
hook/build.dart를 만들거나 열어요. main메서드에서package:hooks/hooks.dart의build함수를 호출하고, 적절한 툴체인으로 네이티브 라이브러리를 컴파일해요. 예를 들면:
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.recordedUses가 null이면 링크 훅은 tree-shaking을 끄고 모든 심볼을 유지해요.
- Dart 프로젝트에서
hook/link.dart를 만들거나 열어요. main메서드에서package:hooks/hooks.dart의link함수를 호출하고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에 넘겨 주세요.
symbolsToKeep가 null이면(예: input.recordedUses가 null일 때), LinkerOptions.treeshake는 네이티브 라이브러리의 모든 심볼을 유지해요. symbolsToKeep가 빈 목록([]), 즉 애플리케이션이 그 라이브러리의 어떤 심볼도 참조하지 않는다면, CLibrary.link는 동적 라이브러리를 컴파일하고 번들링하는 것을 자동으로 건너뛰어요.
자동 번들링되는 에셋
훅은 run, build, test 명령을 호출할 때 자동으로 실행돼요. 결과로 만들어진 에셋은 훅 입력에 지정된 출력 디렉토리에 저장돼요. 그런 다음 Dart SDK가 그 에셋들을 Dart 앱에 자동으로 번들링해서 런타임에 접근할 수 있게 해 줘요.
에셋 사용하기
에셋은 훅이 만드는 파일이에요. 에셋이 만들어지면 코드와 런타임에서 그 에셋 ID(assetId)로 참조할 수 있어요. 에셋 ID는 package:<패키지 이름>/<에셋 이름> 형태로 구성돼요. 빌드 훅은 자기 패키지 안의 에셋만 출력할 수 있어요. 앞 예제의 빌드 훅에 있는 CLibrary는 packageName과 assetName을 기반으로 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.dart나 link.dart 훅 스크립트에서 구성한 user-defines에 접근하려면 input.userDefines 객체를 쓰면 돼요.
- 대괄호 연산자로 원시 값을 읽어요. 예:
input.userDefines['key']. path()메서드로 상대 경로를Uri로 해석할 수도 있어요. 예:input.userDefines.path('key'). 이 메서드는 user-defines가 선언된pubspec.yaml의 디렉토리를 기준으로 상대 경로를 해석해요.
훅이 해석된 파일이나 디렉토리를 읽는다면, output.dependencies.add()로 그걸 의존성으로 등록해 줘요. 이렇게 해야 파일이 바뀌었을 때 빌드 시스템이 캐시를 무효화하고 훅을 다시 실행해요.
enable_experimental과 custom_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 앱에서 네이티브 코드에 바인딩하기
더 알아보기
- FFI(네이티브 상호운용) 기초: Dart 언어 투어 — C 상호운용(Foreign Function Interface)
dart:ffi자세히 보기: dart:ffi- 네이티브 에셋 최신 자료: Dart SDK — Hooks 패키지