2015년 10월 20일, 넥서스 5x 출시를 시작으로 안드로이드 6.0 마시멜로우(이하 M버전)가 정식으로 시장에 풀렸습니다. M버전은 고객의 입장에서는 체감할만한 큰 변화가 없지만, 개발자들에게는 매우 많은 변화가 있던 업데이트 였습니다. 그만큼 구글에서는 정식으로 출시하기 전부터 3차례에 걸쳐 프리뷰를 발표하면서 많은 개발자들이 변화에 대응할 수 있는 시간을 주었습니다. (심지어 targetSdkVersion을 23으로 올리지 않아도 M버전 기기에서 앱이 잘 돌아갑니다. 권장되는 방법은 아닙니다.)
리멤버 또한 구글이 준 유예기간 동안 프리뷰 이미지 및 오픈 테스트 랩 등을 활용해 새로운 안드로이드 M버전에 대응하였습니다. 그 과정을 공유드리고자 합니다.
M버전, 무엇을 대응해야 하나?
참고링크 : http://googledevkr.blogspot.kr/2015/08/testyourapponandroid60.html
구글에서 내세우는 M버전의 변화들 중 주목해야 할 것은 Doze Mode 및 App Standby Mode, 권한모델, 입니다. 먼저 2가지를 살펴보겠습니다.
1. Doze Mode와 App Standby Mode
위 두가지 모드는 배터리를 절약하기 위해 새로 생긴 모드입니다. 자세한 내용은 아래 링크를 참조해주세요.
참조링크 : http://thdev.net/632
각 모드들은 일정시간이상 어떠한 조건들을 모두 만족시키고 있다면 돌입하게 됩니다. 기준시간은 제조사마다 차이가 있지만 평균적으로 2시간 입니다. 모드 시작조건은 다음과 같습니다.
• 사용자로부터 인터렉션이 일어나지 않고, 화면이 꺼져있다.
• 충전 중이 아니다.
• 디바이스가 움직이지 않는다.
모드가 시작되면 백그라운드 작업이 대부분 불가능 해집니다. 알람과 같은 경우에는 치명적으로 작용할 수 있습니다. 그러나 다행히 리멤버는 특정 시간에 해야하는 작업이 없고, 가장 긴 시간동안 일어나는 백그라운드 작업인 명함 동기화 또한 길어야 10분 내에 완료됩니다. 그래서 우리는 Doze Mode와 App Standby Mode가 UX에 영향을 주지 않는다고 판단하여 대응하지 않기로 결정하였습니다.
만약 자신의 앱은 위 모드들을 대응해야 된다면 아래 링크를 참조해 주세요.
참고링크
http://developer.android.com/intl/ko/training/monitoring-device-state/doze-standby.html
http://googledevkr.blogspot.kr/2015/10/gcmonandroid-doze.html
2. 권한모델
M버전의 대응의 90%는 권한모델을 넣는 것 이였습니다. 그만큼 권한모델은 기존 UX 흐름에 큰 변화를 주었습니다. 먼저 어떻게 바뀌었는지 알아보겠습니다.
기존에는 사용자가 Play Store에서 앱을 다운받을 때 요구하는 권한을 모두 동의해야 했습니다. 이것은 개발자가 AndroidManifest.xml에 필요한 권한을 선언만 하면 되었으므로 매우 편한 시스템 이였습니다. 사용자가 권한을 거부하여 생길 수 있는 예외상황은 고려하지 않아도 되었으니까요.
하지만 M버전부터는 구글에서 Dangerous 레벨 이상으로 지정한 특정 권한들에 대해서 앱 사용 중에 사용자들로부터 허락을 받아야 합니다. 마치 아이폰과 같지요. 만약 사용자가 권한을 허용하지 않는다면, 어떠한 작업이 일어날지 예상할 수 없습니다. 개발자는 이제 하나하나 필요한 권한들을 대응해야만 합니다.
앱에서 필요한 권한을 조사하자
우선 리멤버가 무슨 권한을 요구하는지에 대해 알아보았습니다.
사용 권한그룹 | 사용 케이스 |
---|---|
카메라 | 명함촬영 |
주소록 | 명함 연락처 저장, 구글 연락처 동기화, 구글계정 로그인 |
전화 | 전화수신팝업 |
Storage | 명함 사진으로 가져오기, 명함첩 파일로 내보내기 |
SMS | Main 단체 SMS, Namecard SMS |
사용 개별권한 | 사용 케이스 | 권한그룹 |
---|---|---|
WRITE_EXTERNAL_STORAGE | 파일 내보내기, 임시 명함파일 저장 | 저장 |
SYSTEM_ALERT_WINDOW | 전화수신팝업 | 별도설정 |
CAMERA | 카메라 | 카메라 |
READ_CONTACTS | 명함 연락처 저장 | 주소록 |
WRITE_CONTACTS | 명함 연락처 저장 | 주소록 |
READ_PHONE_STATE | 전화수신팝업 | 전화 |
GET_ACCOUNTS | 구글 연락처 동기화, 구글계정 로그인 | 주소록 |
개발자가 사용자에게 요구해야 하는 것은 개별권한입니다. 개별권한은 각각 소속된 권한그룹이 있습니다. 사용자가 설정창에서 명시적으로 허용, 거부하는 단위는 권한그룹입니다. 때문에 필요한 개별권한과 권한그룹을 개발 전에 나누는 것이 중요합니다. 권한과 권한그룹에 대해서는 다음 링크를 참조해주세요.
참조링크
http://developer.android.com/intl/ko/reference/android/Manifest.permission.html
http://developer.android.com/intl/ko/reference/android/Manifest.permission_group.html
사용자에게 필요한 권한을 요청하자
개발자들이 권한모델에 쉽게 대응하도록 하기 위하여 구글에서는 다음과 같은 3가지 메소드를 만들었습니다.
ContextCompat.checkSelfPermission() ActivityCompat.shouldShowRequestPermissionRationale() ActivityCompat.requestPermissions()
3개의 메소드를 활용한 권한요청 가이드는 아래 링크를 참조해주세요.
참고링크 : http://developer.android.com/intl/ko/training/permissions/requesting.html
우리에게 필요한 시나리오에 따라 대응 전략을 세우자
우리는 위 가이드에서 문제가 되는 시나리오 두가지를 발견하였습니다.
- ActivityCompat.requestPermissions()를 통해 나오는 권한요청 다이알로그에서 한번 이상 거부하고, 다시보지 않기를 체크하지 않았어야만 ActivityCompat.shouldShowRequestPermissionRationale()가 true를 반환합니다.
- 다시보지 않기를 체크하고 거부하였을 경우에는 ActivityCompat.requestPermissions()가 자동으로 false를 반환하면서 요청 다이알로그를 띄우지 않습니다.
1번 시나리오 에서는 권한을 한번도 거부하지 않았을 경우와, 다시보지 않기를 체크하였을 경우에, 사용자에게 권한이 필요한 이유를 설명할 다이알로그를 보여줄 수 없습니다.
2번 시나리오 에서는 앱 내에서 사용자가 해당 권한을 허용할 수 있도록 안내할 방법이 사라집니다.
저희는 모든 경우에 수를 커버하지 못하는 ActivityCompat.shouldShowRequestPermissionRationale()를 사용하지 않기로 하였습니다. 그리고 몇가지 반복작업을 하나로 묶어 편하게 권한을 요청하면서도, 사용자가 다시보지 않기를 체크해도 권한을 허용할 수 있도록 안내하기 위한 PermissionUtil을 만들기로 하였습니다. 생각하는 시나리오는 다음과 같습니다.
- 권한을 check한다. 허용되어 있다면 정상 작업을 실행한다.
- 권한을 check한다. 허용이 안되어있다면 request를 한다.
- request를 통해 허용 값을 받는다면 정상 작업을 실행한다.
- request를 통해 거부 값을 받는다면 우리가 자체적으로 만든 Rationale dialog를 보여준다.
여기에서 핵심은 4번입니다. 만약 사용자가 다시보지 않기를 체크하고 권한을 거부하더라도, 앱 사용에 필수적인 권한을 허용하도록 설득하면서, 별도로 설정할 수 있도록 돕는 다이알로그를 띄웁니다.
PermissionUtil에서는 다음과 같은 메소드들을 이용하여 권한을 요청합니다.
public static boolean checkAndRequestPermission(Activity activity, int permissionRequestCode, String... permissions) { String[] requiredPermissions = getRequiredPermissions(activity, permissions); if (requiredPermissions.length > 0 && !activity.isDestroyedCompat()) { ActivityCompat.requestPermissions(activity, requiredPermissions, permissionRequestCode); return false; } else { return true; } } public static boolean checkAndRequestPermission(Fragment fragment, int permissionRequestCode, String... permissions) { String[] requiredPermissions = getRequiredPermissions(fragment.getContext() != null ? fragment.getContext() : fragment.getActivity(), permissions); if (requiredPermissions.length > 0 && fragment.isAdded()) { fragment.requestPermissions(requiredPermissions, permissionRequestCode); return false; } else { return true; } }
public static String[] getRequiredPermissions(Context context, String... permissions) { List<String> requiredPermissions = new ArrayList<>(); // Context가 null이면 무조건 권한을 요청하도록 requiredPermissions가 존재한다고 reutrn 한다 if (context == null) return requiredPermissions.toArray(new String[1]); for (String permission : permissions) { if (ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED) { requiredPermissions.add(permission); } } return requiredPermissions.toArray(new String[requiredPermissions.size()]); }
getRequiredPermission()로 요청이 필요한 권한을 검사하고, checkAndRequestPermission() 에서 요청 다이알로그를 띄웁니다.
사용자가 권한 값을 가져온 후에 처리하는 과정은 다음과 같습니다.
@Override public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { switch (requestCode) { case PermissionUtil.PERMISSION_CAMERA: if (PermissionUtil.verifyPermissions(grantResults)) { // 권한을 얻었다. Do something with permission } else { // 권한을 얻지 못했다. Show Rational Dialog String message = PermissionUtil.getRationalMessage(mContext, PermissionUtil.PERMISSION_CAMERA); PermissionUtil.showRationalDialog(mContext, message); } break; } }
public static boolean verifyPermissions(int[] grantResults) { // At least one result must be checked. if (grantResults.length < 1) return false; // Verify that each required permission has been granted, otherwise return false. for (int result : grantResults) { if (result != PackageManager.PERMISSION_GRANTED) return false; } return true; }
public static String getRationalMessage(Context context, int code) { switch (code) { case PERMISSION_CAMERA: return getRationalMessage(context, context.getString(R.string.permission_camera_rational), context.getString(R.string.permission_camera)); case PERMISSION_CONTACT: return getRationalMessage(context, context.getString(R.string.permission_contact_rational), context.getString(R.string.permission_contact)); case PERMISSION_STORAGE: return getRationalMessage(context, context.getString(R.string.permission_storage_rational), context.getString(R.string.permission_storage)); case PERMISSION_READ_PHONE_STATE: return getRationalMessage(context, context.getString(R.string.permission_read_phone_state_rational), context.getString(R.string.permission_read_phone_state)); } return ""; } public static String getRationalMessage(Context context, String rational, String permission) { return String.format(context.getString(R.string.permission_request), rational, permission); }
public static void showRationalDialog(Context context, int message) { showRationalDialog(context, context.getString(message)); } public static void showRationalDialog(Context context, String message) { DialogCreator.create(context, message, (dialog, which) -> { try { Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) .setData(Uri.parse("package:" + context.getPackageName())); context.startActivity(intent); } catch (ActivityNotFoundException e) { e.printStackTrace(); Intent intent = new Intent(Settings.ACTION_MANAGE_APPLICATIONS_SETTINGS); context.startActivity(intent); } }, R.string.word_settings, (dialog, which) -> { // Do nothing }, R.string.word_close, 0).show(); }
onRequestPermissionsResult() 에서 사용자가 허용/거부한 값을 받고. true이면 원래의 작업을 합니다. false이면 위 이미지와 같은 Rational Dialog를 띄어줍니다. Rational Message는 string.xml의 format을 이용하여 권한의 이름만 바꿔주며 공통적으로 사용하였습니다. Rational Dialog는 설정버튼을 누르면 리멤버 앱의 설정창으로 넘어가도록 하였습니다. (DialogCreator은 리멤버에서 공통적으로 사용하는 CustomAlertDialog을 보여주기 위한 자체 Util입니다.)
여기까지 사용권한 및 권한그룹 분석, 실제 대응전략 구상까지 끝났다면, 남은 일은 모든 권한이 필요한 곳들을 찾아 체크 메소드를 넣어주는 것입니다. 이 과정에서는 철저한 QA가 필요합니다. UX 시나리오가 이미 방대한 기존의 앱은 권한이 필요한 경우를 놓칠 수 있기 때문입니다. 천천히, 꼼꼼하게 권한모델을 대응합시다.
Fragment에서 권한을 요청한다면?
사용자가 권한을 허용/거부한 결과를 받아주는 onRequestPermissionsResult()는 기존의 onActivityResult() 원리와 같습니다. 그러므로 Fragment에서 onRequestPermissionsResult()을 받는 경우에는 Activity에 막혀 requestCode가 가려지지 않도록 주의해야 합니다. Activity에서 onRequestPermissionsResult()와 onActivityResult()를 오버라이드 하지 않는다면 상관없지만, 만약 한다면, 반드시 처음에 super 메소드를 실행해야 합니다.
그리고 아쉽게도 NestedFragment와 DialogFragment에서는 onRequestPermissionsResult()를 받을 수 없습니다. 위와 같은 경우에는 getParentFragment() 또는 getActivity()를 활용하여 권한을 요청해야 합니다.
System_Window_Alert 권한
리멤버는 전화가 왔을 때 해당하는 번호와 같은 명함이 있을 경우 팝업을 띄어주는 기능이 있습니다. 전화수신 팝업은 안드로이드 윈도우 상에 팝업을 띄어주어야 하므로 system_window_alert 권한을 필요로 합니다. 안드로이드 6.0 이전 버전에는 다른 권한들과 같이 AndroidManifest.xml에 선언만 해주면 되었지만, M버전부터는 위에 설명했던 방법과는 또 다르게 처리해야 하는 특별한 권한입니다.
system_window_alert는 ActivityCompat.requestPermissions()로부터 권한을 얻어낼 수 없습니다. ‘다른 앱 위에 그리기’ 라는 별도의 권한 설정창으로부터 사용자가 직접 허용하도록 유도해야 합니다. 해당 설정창으로 넘어가도록 하는 코드는 다음과 같습니다.
@TargetApi(Build.VERSION_CODES.M) public static void requestOverlayPermission(Activity activity) { activity.startActivityForResult(new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION), PermissionUtil.PERMISSION_OVERLAY); }
설정창으로 넘어가게 하는 것은 쉽지만, 사용자에게 낯선 권한을 허용받기 위해서는 왜 이 권한이 필요한지를 잘 설명하는 것이 중요하겠습니다.
‘다른 앱 위에 그리기’ 권한을 사용할 수 있는지 여부는 Settings.canDrawOverlays()를 이용해 알 수 있습니다. 하지만 아쉽게도 system_window_alert는 다른 권한들처럼 onRequestPermissionsResult()으로 사용자가 권한을 허용/거부한 결과값을 알수 없습니다. 그래서 저희는 위 코드에서도 보이듯이 startActivityForResult()로 요청하여 onActivityResult()에서 결과값을 받아 작업을 실행하였습니다.
@Override public void onActivityResult(int requestCode, int resultCode, Intent data) { switch (requestCode) { case PermissionUtil.PERMISSION_OVERLAY: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Settings.canDrawOverlays(getContext())) { // Do something with overlay permission } else { // Show dialog which persuades that we need permission } } break; } }
전화수신 팝업은 system_window_alert 권한뿐만 아니라 전화수신 상태를 캐치하는 read_phone_state 권한도 요구합니다. 따라서 일반적인 권한처리 방법도 혼용하여 값을 받아야 사용이 가능합니다. 사용자에게 권한을 다른방법으로, 여러번 요청하는 불편함을 초래하지만, 구글에서 그만큼 중요한 권한이라고 판단하여 위험레벨을 높게 설정하였기 때문에, 이렇게 해야만 합니다.
다른 사항은 없었나?
구글 코리아에서 정리한 M버전을 대응하면서 나타날 수 있는 일반적인 예외 케이스들을 보고 싶다면 아래 링크를 참조해주세요.
참고링크 : http://googledevkr.blogspot.kr/2015/09/testyourapponandroid60.html
리멤버가 겪었던 추가적인 이슈는 다음과 같은 것들이 있었습니다.
최초의 마시맬로우 기기, 넥서스 5x만의 이슈
1. 전화번호가 국제번호로 변경되어서 옵니다
전화가 왔을 때 통신사, 제조사의 차이에 따라 번호가 국제번호로 변환되어 옵니다. 리멤버에는 전화가 오면 번호에 따라서 명함을 검색 후 팝업을 띄어주는 기능이 있는데, 불규칙하게 번호가 변경되는 바람에, 보유하고 있음에도 명함을 검색하지 못하는 경우가 있었습니다. 우리는 전화수신 팝업을 제대로 띄어주기 위해, 전화 온 번호를 명함에 저장되어진 번호와 같은 형식으로 클린징 하는 과정을 추가해야 했습니다.
2. 카메라가 180도 돌아가 있습니다
넥서스 5x는 이미지 센서가 다른 기기들과 다르게 180도 돌아가 있습니다. 그래서 따로 처리를 해주지 않았다면, 카메라의 방향이 기본적으로 상하좌우 반전되어 나옵니다. 우리는 카메라가 돌아가 있지 않은 경우에는 그대로, 돌아가 있는 경우에는 Preview를 반대로 돌려주는 작업을 추가해야 했습니다. 이미지 센서가 인식하는 카메라의 방향을 알아내고, Preview를 돌려주는 작업은 아래의 링크를 참조해주세요.
대부분의 앱은 위와 같이 처리하면 쉽게 해결될 것입니다. 하지만 우리는 보여주는 것 뿐만 아니라 명함을 자동인식하는 좌표, 수동으로 편집하기 위해 잡는 좌표 등 정확한 위치계산까지 180도 돌려서 해야 했으므로 새로운 행렬 계산식을 추가해야 했습니다. 부디 이 글을 읽는 분들께서는 이런 경우가 아니기를 바랍니다.
테스트를 힘들게 하는 것
테스트를 할 때에는 권한을 자주 껐다 키면서 다양한 경우를 빠르게 확인해야 합니다. 때문에 설정창과 앱을 동시에 켜놓고 스택창을 통해 넘나들면서 앱을 실행합니다. 하지만 설정창에서 명시적으로 권한을 거부한 후 앱으로 돌아오면, 당시에 가장 상단으로 나와있던 Activity가 onCreate()부터 다시 실행됩니다. 이 말은, 한 Activity에서 여러 Fragment를 바꿔가며 사용하는 경우에는, 첫 Fragment로 돌아간다는 것입니다. 일반적인 유저가 겪기는 힘든 케이스지만, 테스트 중에는 우리를 매우 힘들게 했던 현상 중 하나였습니다.
마무리
안드로이드 5.0 롤리팝 때에는 많은 변화에 비해 개발자를 위한 가이드가 적었습니다. 하지만 6.0은 많은 가이드들을 통해 빠르게 대응할 수 있도록 잘 준비되어 있습니다. 이전에는 디자인으로 사용자 경험을 개선하려 했다면, 6.0에서는 이 글에서 다룬 베터리 절약, 권한모델 외에도 앱 데이터 자동저장, Direct Share, 지문인식 등 다양한 디테일을 통해 사용자 경험을 높이고자 하는 구글의 노력이 보였던 업데이트 였습니다. 혹시 아직 안드로이드 6.0을 대응하지 않으셨나요? 지금이라도 적용을 시작해보세요. 개선된 사용자 경험을 얻을 수 있습니다.
ㅠ ㅠ
제가 위의 소스를 보며 만들어 보고 있으나
isDestroyedCompat()
case PermissionUtil.PERMISSION_CAMERA:
String message = PermissionUtil.getRationalMessage(mContext, PermissionUtil.PERMISSION_CAMERA); 에서 에러가 뜨네요
ㅠㅠㅠ
여기서 감사히 잘 참고 했습니다. 그런데 혹시 System_Window_Alert 권한에서PermissionUtil.PERMISSION_OVERLAY 메소드를 사용할려고 할때 PermissionUtil클래스가 없는데 혹시 PermissionUtil 클래스를 만들어줘야 되나요?
PermissionUtil.PERMISSION_OVERLAY는 startActivityForResult의 파라미터로 넘기는 코드입니다. PermissionUtil을 만들지 않아도 코드를 하나 지정하여 사용하시면 됩니다.