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
}
}
}