Headless JS
Headless JS는 앱이 백그라운드에 있을 때도 자바스크립트 작업을 실행할 수 있는 방법이다. 최신 데이터를 동기화하거나, 푸시 알림을 처리하거나, 음악을 재생하는 등의 용도로 활용할 수 있다.
JS API
태스크는 AppRegistry
에 등록하는 비동기 함수다. React 애플리케이션을 등록하는 방식과 유사하다:
import {AppRegistry} from 'react-native';
AppRegistry.registerHeadlessTask('SomeTaskName', () =>
require('SomeTaskName'),
);
그리고 SomeTaskName.js
파일에서 다음과 같이 작성한다:
module.exports = async taskData => {
// 작업 수행
};
태스크 내에서는 UI를 건드리지 않는 한 네트워크 요청이나 타이머 등 무엇이든 할 수 있다. 태스크가 완료되면(Promise가 resolve되면), React Native는 "일시 정지" 모드로 전환된다(다른 태스크가 실행 중이거나 포그라운드 앱이 있는 경우는 제외).
플랫폼 API
여전히 약간의 네이티브 코드가 필요하지만, 매우 간결하다. HeadlessJsTaskService
를 상속받고 getTaskConfig
를 오버라이드하면 된다. 예를 들면 다음과 같다:
- Java
- Kotlin
package com.your_application_name;
import android.content.Intent;
import android.os.Bundle;
import com.facebook.react.HeadlessJsTaskService;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.jstasks.HeadlessJsTaskConfig;
import javax.annotation.Nullable;
public class MyTaskService extends HeadlessJsTaskService {
@Override
protected @Nullable HeadlessJsTaskConfig getTaskConfig(Intent intent) {
Bundle extras = intent.getExtras();
if (extras != null) {
return new HeadlessJsTaskConfig(
"SomeTaskName",
Arguments.fromBundle(extras),
5000, // 태스크 타임아웃 (밀리초)
false // 선택사항: 포그라운드에서 태스크 허용 여부. 기본값은 false
);
}
return null;
}
}
package com.your_application_name;
import android.content.Intent
import com.facebook.react.HeadlessJsTaskService
import com.facebook.react.bridge.Arguments
import com.facebook.react.jstasks.HeadlessJsTaskConfig
class MyTaskService : HeadlessJsTaskService() {
override fun getTaskConfig(intent: Intent): HeadlessJsTaskConfig? {
return intent.extras?.let {
HeadlessJsTaskConfig(
"SomeTaskName",
Arguments.fromBundle(it),
5000, // 태스크 타임아웃
false // 선택사항: 포그라운드에서 태스크 허용 여부.
// 기본값은 false
)
}
}
}
그런 다음 AndroidManifest.xml
파일의 application
태그 안에 서비스를 추가한다:
<service android:name="com.example.MyTaskService" />
이제 서비스를 시작할 때마다, 예를 들어 주기적 작업으로 또는 시스템 이벤트/브로드캐스트에 대한 응답으로, JS가 실행되고 태스크를 수행한 후 종료된다.
예제:
- Java
- Kotlin
Intent service = new Intent(getApplicationContext(), MyTaskService.class);
Bundle bundle = new Bundle();
bundle.putString("foo", "bar");
service.putExtras(bundle);
getApplicationContext().startForegroundService(service);
val service = Intent(applicationContext, MyTaskService::class.java)
val bundle = Bundle()
bundle.putString("foo", "bar")
service.putExtras(bundle)
applicationContext.startForegroundService(service)
재시도
기본적으로 헤드리스 JS 태스크는 재시도를 수행하지 않는다. 재시도를 구현하려면 HeadlessJsRetryPolicy
를 생성하고 특정 Error
를 던져야 한다.
LinearCountingRetryPolicy
는 HeadlessJsRetryPolicy
의 구현체로, 최대 재시도 횟수와 각 시도 간의 고정된 지연 시간을 설정할 수 있다. 이 정책이 필요에 맞지 않다면 직접 HeadlessJsRetryPolicy
를 구현할 수 있다. 이러한 정책은 HeadlessJsTaskConfig
생성자에 추가 인자로 전달할 수 있다. 예를 들면 다음과 같다.
- Java
- Kotlin
HeadlessJsRetryPolicy retryPolicy = new LinearCountingRetryPolicy(
3, // 최대 재시도 횟수
1000 // 각 재시도 간 지연 시간(밀리초)
);
return new HeadlessJsTaskConfig(
'SomeTaskName',
Arguments.fromBundle(extras),
5000,
false,
retryPolicy
);
val retryPolicy: HeadlessJsTaskRetryPolicy =
LinearCountingRetryPolicy(
3, // 최대 재시도 횟수
1000 // 각 재시도 간 지연 시간(밀리초)
)
return HeadlessJsTaskConfig("SomeTaskName", Arguments.fromBundle(extras), 5000, false, retryPolicy)
재시도는 특정 Error
가 발생했을 때만 시도된다. 헤드리스 JS 태스크 내부에서 해당 오류를 임포트하고, 재시도가 필요할 때 오류를 던질 수 있다.
예제:
import {HeadlessJsTaskError} from 'HeadlessJsTask';
module.exports = async taskData => {
const condition = ...;
if (!condition) {
throw new HeadlessJsTaskError();
}
};
모든 오류에 대해 재시도를 수행하려면, 오류를 캐치한 후 위의 오류를 던져야 한다.
주의사항
- 기본적으로 앱이 포그라운드에서 실행 중일 때 태스크를 실행하려고 하면 앱이 강제 종료된다. 이는 개발자가 태스크에서 많은 작업을 수행해 UI를 느리게 만드는 실수를 방지하기 위한 조치다. 이 동작을 제어하려면 네 번째
boolean
인자를 전달할 수 있다. BroadcastReceiver
에서 서비스를 시작할 때는onReceive()
에서 반환하기 전에 반드시HeadlessJsTaskService.acquireWakeLockNow()
를 호출해야 한다.
예제 사용법
서비스는 Java API를 통해 시작할 수 있다. 먼저 서비스를 언제 시작할지 결정하고, 그에 맞는 솔루션을 구현해야 한다. 다음은 네트워크 연결 변경에 반응하는 예제이다.
아래 코드는 브로드캐스트 리시버를 등록하기 위한 Android 매니페스트 파일의 일부를 보여준다.
<receiver android:name=".NetworkChangeReceiver" >
<intent-filter>
<action android:name="android.net.conn.CONNECTIVITY_CHANGE" />
</intent-filter>
</receiver>
브로드캐스트 리시버는 onReceive 함수에서 브로드캐스트된 인텐트를 처리한다. 이곳은 앱이 포그라운드에 있는지 확인하기에 적합한 위치이다. 앱이 포그라운드에 있지 않다면, putExtra
를 사용해 추가 정보를 포함하거나 포함하지 않고 인텐트를 준비할 수 있다. (bundle은 parcelable 값만 처리할 수 있음을 유의해야 한다.) 마지막으로 서비스를 시작하고 wakelock을 획득한다.
- Java
- Kotlin
import android.app.ActivityManager;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.net.ConnectivityManager;
import android.net.Network;
import android.net.NetworkCapabilities;
import android.net.NetworkInfo;
import android.os.Build;
import com.facebook.react.HeadlessJsTaskService;
public class NetworkChangeReceiver extends BroadcastReceiver {
@Override
public void onReceive(final Context context, final Intent intent) {
/**
이 부분은 네트워크 연결이 변경될 때마다 호출된다
예: 연결됨 -> 연결 안 됨
**/
if (!isAppOnForeground((context))) {
/**
서비스를 시작하고 네트워크 연결에 대한 추가 정보를 전송한다
**/
boolean hasInternet = isNetworkAvailable(context);
Intent serviceIntent = new Intent(context, MyTaskService.class);
serviceIntent.putExtra("hasInternet", hasInternet);
context.startForegroundService(serviceIntent);
HeadlessJsTaskService.acquireWakeLockNow(context);
}
}
private boolean isAppOnForeground(Context context) {
/**
앱이 포그라운드에 있는지 확인해야 한다. 그렇지 않으면 앱이 크래시될 수 있다.
https://stackoverflow.com/questions/8489993/check-android-application-is-in-foreground-or-not
**/
ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
List<ActivityManager.RunningAppProcessInfo> appProcesses =
activityManager.getRunningAppProcesses();
if (appProcesses == null) {
return false;
}
final String packageName = context.getPackageName();
for (ActivityManager.RunningAppProcessInfo appProcess : appProcesses) {
if (appProcess.importance ==
ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND &&
appProcess.processName.equals(packageName)) {
return true;
}
}
return false;
}
public static boolean isNetworkAvailable(Context context) {
ConnectivityManager cm = (ConnectivityManager)
context.getSystemService(Context.CONNECTIVITY_SERVICE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Network networkCapabilities = cm.getActiveNetwork();
if(networkCapabilities == null) {
return false;
}
NetworkCapabilities actNw = cm.getNetworkCapabilities(networkCapabilities);
if(actNw == null) {
return false;
}
if(actNw.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || actNw.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || actNw.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
return true;
}
return false;
}
// API 레벨 29에서 deprecated 됨
NetworkInfo netInfo = cm.getActiveNetworkInfo();
return (netInfo != null && netInfo.isConnected());
}
}
import android.app.ActivityManager
import android.app.ActivityManager.RunningAppProcessInfo
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import com.facebook.react.HeadlessJsTaskService
class NetworkChangeReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
/**
* 이 부분은 네트워크 연결이 변경될 때마다 호출된다. 예: 연결됨 -> 연결 안 됨
*/
if (!isAppOnForeground(context)) {
/** 서비스를 시작하고 네트워크 연결에 대한 추가 정보를 전송한다 */
val hasInternet = isNetworkAvailable(context)
val serviceIntent = Intent(context, MyTaskService::class.java)
serviceIntent.putExtra("hasInternet", hasInternet)
context.startForegroundService(serviceIntent)
HeadlessJsTaskService.acquireWakeLockNow(context)
}
}
private fun isAppOnForeground(context: Context): Boolean {
/**
* 앱이 포그라운드에 있는지 확인해야 한다. 그렇지 않으면 앱이 크래시될 수 있다.
* https://stackoverflow.com/questions/8489993/check-android-application-is-in-foreground-or-not
*/
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val appProcesses = activityManager.runningAppProcesses ?: return false
val packageName: String = context.getPackageName()
for (appProcess in appProcesses) {
if (appProcess.importance == RunningAppProcessInfo.IMPORTANCE_FOREGROUND &&
appProcess.processName == packageName
) {
return true
}
}
return false
}
companion object {
fun isNetworkAvailable(context: Context): Boolean {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
var result = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val networkCapabilities = cm.activeNetwork ?: return false
val actNw = cm.getNetworkCapabilities(networkCapabilities) ?: return false
result =
when {
actNw.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
actNw.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
actNw.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true
else -> false
}
return result
} else {
cm.run {
// API 레벨 29에서 deprecated 됨
cm.activeNetworkInfo?.run {
result =
when (type) {
ConnectivityManager.TYPE_WIFI -> true
ConnectivityManager.TYPE_MOBILE -> true
ConnectivityManager.TYPE_ETHERNET -> true
else -> false
}
}
}
}
return result
}
}
}