Category Archives: Android

안드로이드 테마 기능 적용하기

안드로이드 10부터 시스템 수준에서 어두운 테마(Dark theme)를 지원하기 시작했고, 이제 안드로이드 앱들은 테마(밝은/어두운/시스템) 기능을 필수적으로 지원해야 할 때이다. 어두운 테마로 변경하면, 앱에서 사용하는 컴포넌트의 색을 어두운 계열로 변화 시켜 시인성을 높이고, 사용자 눈의 피로도를 줄이고, 배터리의 사용을 절약시키는 이점도 부가적으로 얻을 수 있다.

  
– 안드로이드 10 에뮬레이터의 설정 화면에서 어두운 테마 켜기

안드로이드 10에서 사용자가 설정 화면에서 어두운 테마를 선택했다면, 설치된 앱들도 어두운 색상으로 동작하길 원할 것이기에 사용자에게 일관적인 UI/UX를 경험할 수 있게 하기에 테마 기능 적용은 중요한 UX 요구사항이라고 볼 수 있게 되었다. 테마 변경을 살펴보기 전에 안드로이드 화면의 색상값에 대해서 한번 살펴보자.


– 안드로이드 화면에서의 테마 색상 요소 값

위 테마 색상은 스타일 파일(/res/styles.xml)에서 아래와 같이 확인할 수 있다.

앱에서 AppCompat 테마를 사용하는 경우에는, parent=”Theme.AppCompat.DayNight”로 선언을 해야 하고, MaterialComponents 테마를 사용하는 경우, parent=”Theme.MaterialComponents.DayNight”로 선언해야 한다.

이제 테마 기능을 추가해 보자. 테마 기능을 추가하는데 필요한 구성은 다음과 같다:

– 테마별 색상 변경을 위한 폴더 구조
– 스타일 파일(styles.xml)의 변경 요소들
– 테마 변경을 위한 코드 작성
– 테마 변경과 관련된 이슈들

1. 테마별 색상 변경을 위한 폴더 구조

1.1 테마 파일과 색상 파일

안드로이드 앱에서 기본값으로 읽는 테마와 색상 정보는 /res/values/ 폴더에 스타일 파일(styles.xml)과 색상 파일(colors.xml)에 선언한다. 어두운 테마가 읽는 테마 정보와 색상 정보는 /res/values-night/ 폴더에 같은 파일 이름으로 선언한다. 많은 경우에는 /res/values-night/ 폴더에 색상 파일만 위치시켜도 테마 기능이 잘 동작한다.

1.2 이미지 파일

만약 밝은 테마와 어두운 테마별로 보이는 이미지가 다른 경우, 밝은 테마에 보이는 이미지는 drawable-..hdpi 폴더에 어두운 테마에 보이는 이미지는 darwable-night-…hdpi 폴더에 넣어준다. mipmap을 사용하는 경우에도 drawable과 같은 패턴으로 mipmap-night-… 폴더를 사용하면 된다.

테마 변경 결과 화면을 먼저 살펴보자.

   

이 화면과 예제 소스는 https://github.com/mcsong/ThemeDemo 에서 확인할 수 있다.

2. 스타일 파일에서의 변경 요소들

스타일 파일(styles.xml)에서의 요소들에 대해서 살펴보자. 안드로이드 테마를 변경하는 경우에 색상 파일(colors.xml)만 변경해도 잘 동작할 것이다. 하지만, 시작 화면(Splash Screen) 테마의 경우에는 안드로이드 10 이후부터 어두운 테마에 어울리는 스타일을 읽어 들여야 하므로 /res/values-night/ 의 스타일 파일에 시작 화면 테마도 선언해야 한다.

시작 화면 테마는 앱이 화면(MainActivity가 주로 로딩)을 보이기 전에 보이는 화면의 구성이라고 보면 된다. 안드로이드 앱을 실행하면 런타임이 앱을 읽어 들이는 시간에 사용자가 느리다는 것을 인지하게 되고, 이것을 개선하는 방법으로 시작 화면 테마 사용을 권장하고 있다. 간단한 예제는 아래의 링크에서 확인할 수 있다.

시작 화면 테마 구성 방법 : https://android.jlelse.eu/launch-screen-in-android-the-right-way-aca7e8c31f52

예제 프로젝트에서도 시작 화면 테마를 사용하고 있으니, 예제 프로젝트의 소스를 살펴보면 시작 화면 테마를 어떻게 사용하고 있는지 알 수 있다. 이제 스타일 파일을 살펴보자.

    
    




– /res/values/styles.xml 파일

이 파일은 밝은 테마를 구성하는 스타일 파일이고, 밝은 계열의 색으로 배경을 구성한다. 그리고, android:windowLightNavigationBar의 값을 true로 설정해서 안드로이드 기기 아래의 컨트롤 버튼을 잘 보이게 한다.

    
    






– /res/values-night/styles.xml 파일

이 파일은 어두운 테마를 구성하는 스타일 파일이고, 어두운 계열의 색으로 배경을 구성한다. 그리고, android:windowLightNavigationBar의 값을 false로 설정해서 아래 컨트롤 버튼을 잘 보이게 한다.

3. 테마 변경을 위한 코드 작성

테마 변경을 위한 소스 코드는 https://github.com/mcsong/ThemeDemo을 기준으로 살펴보자.

package net.sjava.examples.theme;
import android.app.Application;

public class ThemeApp extends Application {

  @Override
  public void onCreate() {
    super.onCreate();

    ThemeUtil.applyTheme(this);
  }
}

– ThemeApp.java 소스 코드

이 클래스는 어플리케이션 소스를 상속받아서, 앱을 실행하면 가장 먼저 실행해서 예제 앱에서 변경한 테마를 읽게 한다.

package net.sjava.examples.theme;

import android.content.Context;
import android.os.Build;

import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatDelegate;

public class ThemeUtil {

  static final String THEME_KEY = "theme_value";

  public static void applyTheme(@NonNull Context context) {
    int option = SharedPrefsUtil.getInt(context, THEME_KEY, 0);
    applyTheme(context, option);
  }

  // 0 : light, 1 : dark, 2 : daytime
  public static void applyTheme(@NonNull Context context, int option) {
    // 테마 값 저장
    SharedPrefsUtil.putInt(context, THEME_KEY, option);

    if (option == 0) {
      AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
      return;
    }

    if (option == 1) {
      AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES);
      return;
    }

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
      AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
    } else {
      AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY);
    }
  }
}

– ThemeUtil.java 소스 코드

이 소스는 실제로 테마를 변경하는 소스로, 테마를 변경하면 변경된 값을 저장한다. 시스템에 따라 테마를 활성화시키는 것을 살펴보면, 안드로이드 10에서는 설정에서 어두운 테마를 활성화시킬 수 있고, 안드로이드 9와 이하 버전에서는 배터리가 절전 모드인 경우 또는 현재 시간이 저녁시간대인 경우에 어두운 테마가 활성화 된다. 그래서 안드로이드 10에서는 AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM의 값으로, 안드로이드 10 이하 버전에서는 AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY의 값으로 시스템에 적용된 테마를 앱에 적용할 수 있다.

    findViewById(R.id.theme_light_button).setOnClickListener(new View.OnClickListener() {
      @Override
      public void onClick(View v) {
        ThemeUtil.applyTheme(v.getContext(), 0);
      }
    });

    findViewById(R.id.theme_dark_button).setOnClickListener(new View.OnClickListener() {
      @Override
      public void onClick(View v) {
        ThemeUtil.applyTheme(v.getContext(), 1);
      }
    });

    findViewById(R.id.theme_system_default_button).setOnClickListener(new View.OnClickListener() {
      @Override
      public void onClick(View v) {
        ThemeUtil.applyTheme(v.getContext(), 2);
      }
    });

– 테마를 변경하는 소스

예제 프로젝트의 MainActivith.java 소스를 살펴보면, 위의 코드를 확인할 수 있다. 개별 버튼을 누르면 테마가 변경되는 것을 확인할 수 있다.

4. 테마 변경과 관련된 이슈들

이제 앱에서 테마를 쉽게 변경할 수 있다. 하지만, 이것도 기존의 앱에서 업그레이드하다 보면 문제가 발생 할 수 있고, 테마 기능을 추가하면서 문제가 된 이슈에 대해서 살펴보자.

우선, 코드상에서 안드로이드 10 버전에 추가된 API를 확인하려면, compileSdkVersion 29로 컴파일 SDK 버전을 맞추면 된다. 그리고, 빌드하는 앱의 SDK 버전은 28 또는 29로 맞추면 되겠다. 혹시 29를 사용하면 문제가 있는 경우에는 targetSdkVersion을 28로 설정해서 컴파일해도 된다. 그래서 빌드 파일(build.xml)에 아래와 같이 사용하면 된다.

android {
   compileSdkVersion 29

   defaultConfig {
      applicationId "net.sjava.appstore.demo"
      minSdkVersion 15
      targetSdkVersion 28
      versionCode 1
      versionName "1.0"
   }
......
}

안드로이드 앱에서 테마를 변경하려면 액티비티(Activity)를 다시 생성해야 한다. 그래서, 프레그먼트(Fragment)에서 테마를 변경하려면 테마를 변경하고, 액티비티의 recreate() 메서드를 수동으로 호출해야 했다. 최신 androidx.appcompat:appcompat:1.1.0 를 사용하면, 액티비티의 recreate() 메서드를 호출하지 않아도 액티비티를 다시 생성해서 테마 변경을 바로 확인할 수 있게 한다.

그리고, 아래와 같이 configChange의 uiMode를 선언한 경우에는 시스템에서 어두운 테마로 변경해도 액티비티의 recreate()가 호출되지 않기 때문에 주의해야 한다.


마지막으로 외부 라이브러리를 사용한 경우 테마별 사용한 색이 달라서 이질감이 들 수 있는 경우가 있다. 사용하는 라이브러리에서 안드로이드 기본색을 사용하지 않고 자신의 색을 사용하는 경우에 이런 문제가 발생할 수 있다. 이 경우 라이브러리 소스를 가져와서 소스를 수정해서 사용하는 방법으로 해결하면 된다.

+ 예제 프로젝트 설명

+ reference

– 테마 예제: https://github.com/mcsong/ThemeDemo
– 앱 시작 시간: https://developer.android.com/topic/performance/vitals/launch-time#java
– 머티리얼 디자인 적용: https://developer.android.com/guide/topics/ui/look-and-feel?hl=ko
– 스타일 및 테마: https://developer.android.com/guide/topics/ui/look-and-feel/themes?hl=ko
– 어두운 테마: https://developer.android.com/guide/topics/ui/look-and-feel/darktheme?hl=ko

웹뷰(WebView) 캐시 설정

종종 멀티 플랫폼을 지원하는 서비스를 사용해 보면, 앱에서 웹뷰(WebView)를 사용해서 서버의 페이지를 보여주는 경우를 쉽게 볼 수 있다. 하지만, 네트웍이 안 되는 경우에는 아래와 같은 에러 페이지를 볼 수 있다. 사용자 관점에서 이 화면은 아주 안 좋은 경험(UX)을 줄 수 있고, 이 경우를 개선하기 위해서 네트웍을 사용할 수 없는 경우에 마지막에 본 페이지를 캐시해서 보여주기도 한다.

그래서, 네트웍을 사용할 수 없는 경우, 위의 에러 화면이 아니라 마지막으로 본 페이지를 보여주는 방법을 간단하게 살펴보면 아래와 같다.

public static void setWebView(Context context) {
	webView.getSettings().setAppCachePath(getContext().getCacheDir().getAbsolutePath());
	webView.getSettings().setAllowFileAccess(true);
	webView.getSettings().setAppCacheEnabled(true);
	webView.getSettings().setCacheMode(WebSettings.LOAD_DEFAULT);

	// 네트웍 연결여부를 확인한다. 
	if(!isNetworkAvailable(context)) {
	  webView.getSettings().setCacheMode( WebSettings.LOAD_CACHE_ELSE_NETWORK );
	}
}

public static boolean isNetworkAvailable(Context context) {
  ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
  NetworkInfo netInfo = cm.getActiveNetworkInfo();
  return netInfo != null && netInfo.isConnected();
}

안드로이드 화면에 실행중인 앱 확인하는 방법

안드로이드 5(롤리팝) 이후부터는 안드로이드 기기에 실행중인 앱을 알 수 없게 되었습니다. 그래서, 안드로이드 5 이후 버전에서는 기기에 저장하고 있는 통계 데이터를 기반으로 완전히 정확하지는 않지만, 대략적으로 정확한 정보를 가져올 수 있습니다. UsageStatsManager를 사용해서 현재 안드로이드 기기의 화면에 실행중인 앱을 검색하는 방법을 살펴보자.

아래는 UsageStatsManager를 사용해서 안드로이드 기기에 저장되어 있는 이벤트 데이터를 검색하고, 이벤트가 포그라운드(Event.MOVE_TO_FOREGROUND 또는 Event.ACTIVITY_RESUMED)인 것을 확인해서 마지막 포그라운드 이벤트에 해당하는 패키지 이름을 가져온다. 이 정보는 대체로 정확하고, 아래 코드로 가져온 패키지가 현재 화면에서 실행중인 것을 확인 할 수 있다.

1. 앱의 AndroidManifest.xml에 아래의 권한을 추가


2. 안드로이드 기기에서 실행중인 앱 가져오기

public static String getTopPackageName(@NonNull Context context) {
  UsageStatsManager usageStatsManager = (UsageStatsManager) context.getSystemService(Context.USAGE_STATS_SERVICE);

  long lastRunAppTimeStamp = 0L;

  final long INTERVAL = 1000 * 60 * 5;
  final long end = System.currentTimeMillis();
  // 1 minute ago
  final long begin = end - INTERVAL;

  LongSparseArray packageNameMap = new LongSparseArray<>();
  final UsageEvents usageEvents = usageStatsManager.queryEvents(begin, end);
  while (usageEvents.hasNextEvent()) {
    UsageEvents.Event event = new UsageEvents.Event();
    usageEvents.getNextEvent(event);

    if(isForeGroundEvent(event)) {
      packageNameMap.put(event.getTimeStamp(), event.getPackageName());
      if(event.getTimeStamp() > lastRunAppTimeStamp) {
        lastRunAppTimeStamp = event.getTimeStamp();
      }
    }
  }

  return packageNameMap.get(lastRunAppTimeStamp, "");
}

3. 이벤트가 포그라운드 이벤트인지 확인

private static boolean isForeGroundEvent(UsageEvents.Event event) {
  if(event == null) {
    return false;
  }

  if(BuildConfig.VERSION_CODE >= 29) {
    return event.getEventType() == UsageEvents.Event.ACTIVITY_RESUMED;
  }

  return event.getEventType() == UsageEvents.Event.MOVE_TO_FOREGROUND;
}

위 코드를 사용해서 완전히 정확하지는 않지만, 대체로 정확하게 안드로이드 화면에서 실행중인 앱을 알 수 있다.

안드로이드 컨텍스트(Context)

안드로이드 컨텍스트(Context)는 리소스에 접근하는 인터페이스이고, 이 인터페이스는 크게 애플리케이션(Application), 액티비티(Activity), 컨텍스트 래퍼(ContextWrapper)의 베이스 컨텍스트(BaseContext)로 3가지 형태를 가진다. 안드로이드를 개발하는 데 있어 특정 상황에서 어떤 컨텍스트를 사용해야 하는지 아는 것은 매우 중요하다. 그래서, 개별 컨텍스트에 대해서 살펴보자.

애플리케이션 컨텍스트
– 애플리케이션은 컨텍스트는 앱 프로세스 자체라고 생각하면 쉽다. 애플리케이션 클래스 자체가 애플리케이션이기 때문에 안드로이드 앱 전반에 걸쳐서 사용할 수 있다. 그리고, 애플리케이션 클래스가 애플리케이션 컨텍스트이기에 라이프 사이클이 애플리케이션하고 같다. getApplicationContext()나 Application 클래스를 상속한 클래스 객체를 사용하면 된다.

액티비티
– 액티비티 컨텍스트도 액비티비 자체라고 생각하면 쉽다. 프레그먼트(Fragment)나 뷰에서 사용할 수 있는 컨텍스트이다. 일반적으로 안드로이드 뷰와 관련된 작업을 하는 데 사용한다. 액티비티 컨텍스트로 액티비티 자체이기에 라이프 사이클이 액티비티와 같다. 보통 액티비티에서는 this, 프레그먼트에서는 getContext()나 getActivity(), 그리고 뷰에서는 getContext()로 컨텍스트를 사용하면 된다.

베이스 컨텍스트
– 자신의 컨텍스트가 아닌 다른 컨텍스트를 접근하기 위해서 사용한다. 보통 getBaseContext()로 컨텍스트를 사용하면 된다.  [ 그림 1. 컨텍스트 클래스 다이어그램 ]

위 그림은 컨텍스트를 어떤 안드로이드 구성요소가 구현하고 있는지 알 수 있고, 위에서 설명한 컨텍스트 객체가 왜 애플리케이션, 액티비티, 컨텍스트 래퍼의 베이스 컨텍스트인지 알 수 있다.

스택오버플로어 사이트에서, 안드로이드 컨텍스트와 사용할 수 있는 기능들을 정리한 이미지를 살펴보면 다음과 같다.


[ 그림 2. 컨텍스트 ]


[ 그림 3. 개별 컨텍스트에서 사용할 수 있는 기능들 ]

안드로이드에서 제공하는 컨텍스트에 대해서 살펴봤다. 그리고 개별 컨텍스트가 제공할 수 있는 기능별 제약이 있기에, 잘 알아서 여러 상황에 맞는 컨텍스트를 사용해야 한다.

레퍼런스
https://stackoverflow.com/questions/3572463/what-is-context-on-android

안드로이드 앱이 동작하는 폼 팩터(Form Factor) 확인 방법

폼 팩터(Form Factor)의 정의를 위키피디아에서 살펴보면 아래와 같다.

컴퓨터 시스템의 각 부품의 물리적 치수의 형태를 의미한다.

즉, 소프트웨어가 동작하는 기기의 형태를 말하는 것이다. 안드로이드 적용범위가 확대되면서, 안드로이드 앱이 실행할 수 있는 하드웨어 기기가 많아졌다. 예로, 크롬북이나 TV등을 예로 들 수 있다.

최근에 안드로이드 앱을 크롬북과 삼성 덱스(DEX)의 UI/UX에 어울리도록 작업을 하면서 특정 기능(키보드 등)은 모바일/태블릿에서만 필요하다. 그래서 앱이 동작하는 기기의 정보를 확인해야 했고, 아래의 코드로 해결을 했다.

아래 코드는 구글 TalkBack 깃헙 사이트의 https://github.com/google/talkback/blob/master/utils/src/main/java/FormFactorUtils.java 코드에 삼성 덱스(DEX)를 확인하는 코드를 추가했다.

아래의 ARC는 App Runtime for Chrome의 줄임말으로 폼 팩터가 크롬북이라는 것을 알 수 있다.

import android.app.UiModeManager;
import android.content.Context;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.os.Build;

public class FormFactorUtils {
	private static final int FORM_FACTOR_PHONE_OR_TABLET = 0;
	private static final int FORM_FACTOR_WATCH = 1;
	private static final int FORM_FACTOR_TV = 2;
	private static final int FORM_FACTOR_ARC = 3;

	private static final int FORM_FACTOR_SAMSUNG_DEX = 100;

	private static final String ARC_DEVICE_PATTERN = ".+_cheets|cheets_.+";

	private static FormFactorUtils sInstance;

	private final int mFormFactor;
	private final boolean mHasAccessibilityShortcut;

	private FormFactorUtils(final Context context) {
		// Find device type.
		if (context.getApplicationContext()
				.getPackageManager()
				.hasSystemFeature(PackageManager.FEATURE_WATCH)) {
			mFormFactor = FORM_FACTOR_WATCH;
		} else if (isContextTelevision(context)) {
			mFormFactor = FORM_FACTOR_TV;
		} else if (Build.DEVICE != null && Build.DEVICE.matches(ARC_DEVICE_PATTERN)) {
			mFormFactor = FORM_FACTOR_ARC;
		} else if(isContextDex(context)) {
			mFormFactor = FORM_FACTOR_SAMSUNG_DEX;
		} else {
			mFormFactor = FORM_FACTOR_PHONE_OR_TABLET;
		}

		// Find whether device supports accessibility shortcut.
		mHasAccessibilityShortcut = (BuildVersionUtils.isAtLeastO() && mFormFactor == FORM_FACTOR_PHONE_OR_TABLET);
	}

	/** @return an instance of this Singleton. */
	public static synchronized FormFactorUtils getInstance(final Context context) {
		if (sInstance == null) {
			sInstance = new FormFactorUtils(context);
		}
		return sInstance;
	}

	/** Return the cached version of the isWatch. */
	public boolean isWatch() {
		return mFormFactor == FORM_FACTOR_WATCH;
	}

	public boolean isArc() {
		return mFormFactor == FORM_FACTOR_ARC;
	}

	public boolean isTv() {
		return mFormFactor == FORM_FACTOR_TV;
	}

	public boolean isPhoneOrTablet() {
		return mFormFactor == FORM_FACTOR_PHONE_OR_TABLET;
	}

	public boolean isDex() {
		return mFormFactor == FORM_FACTOR_SAMSUNG_DEX;
	}

	public boolean hasAccessibilityShortcut() {
		return mHasAccessibilityShortcut;
	}

	public static boolean useSpeakPasswordsServicePref() {
		return BuildVersionUtils.isAtLeastO();
	}

	public static boolean isContextTelevision(Context context) {
		if (context == null) {
			return false;
		}

		UiModeManager modeManager = (UiModeManager) context.getSystemService(Context.UI_MODE_SERVICE);
		return modeManager != null
				&& modeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION;
	}

	private static boolean isContextDex(Context context) {
		if (context == null) {
			return false;
		}

		try {
			Configuration config = context.getResources().getConfiguration();
			Class configClass = config.getClass();
			if (configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass)
					== configClass.getField("semDesktopModeEnabled").getInt(config)) {
				return true;
			}
		} catch (Exception e) {
			// ignore
		}

		return false;
	}
}