[Android / Glide] Glide 이미지 로딩 중 다른 scaleType 적용하기

Glide 동작을 테스트하면서 아래와 같은 상황이 발생하였다.

  1. 기존에 사용하던 로딩이미지의 ScaleType은 CENTER를 사용해야 한다.
  2. Glide로 로드한 이미지는 로딩이미지와는 다른 각자의 ScaleType을 가지고 있다.

따라서 loadingImage와 loadedImage의 ScaleType을 외부에서 설정만 해주면 내부에서 자동으로 바꿔서 보여주도록 만들고자 했다.

이 작업을 위해 Glide에서는 이미지 로딩 중 placeholder 노출, 리소스 로드 등의 동작을 확장 구현할 수 있도록 Target을 제공한다는 점을 먼저 말해두겠다. 이 Target은 into()를 통해 전달할 수 있다.

1
2
3
4
5
6
Target<Drawable> target =
Glide.with(fragment)
.load(url)
.into(new Target<Drawable>() {
...
});

이러한 Target를 상속받는 클래스를 만들어서 placeholder는 onLoadStarted, error는 onLoadFailed에 각각 정의하려고 한다. 이때 로딩이미지는 drawable 리소스로 앱에 저장되어있기 때문에 DrawableImageViewTarget을 상속받아 만든 게 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class ScaleLoadingImageViewTarget extends DrawableImageViewTarget {
private ImageView.ScaleType mLoadingScaleType;
private ImageView.ScaleType mReadyScaleType;

 // RequestOptions에서 정의해둔 scale은 fitCenter
public ScaleLoadingImageViewTarget(ImageView view, ImageView.ScaleType scaleType) {
super(view);
// loadingCenter()에서 받아온 scale
mLoadingScaleType = scaleType;
        // xml에서 정의해둔 scale
// (따로 정의되지 않았다면 여기에 RequestOptions scaleType인 fitCenter가 들어옴
mReadyScaleType = view.getScaleType();
}

@Override
public void onLoadStarted(@Nullable Drawable placeholder) {
ImageView imageView = getView();
imageView.setScaleType(mLoadingScaleType);
super.onLoadStarted(placeholder);
}

@Override
public void onLoadFailed(@Nullable Drawable errorDrawable) {
ImageView imageView = getView();
imageView.setScaleType(mLoadingScaleType);
super.onLoadFailed(errorDrawable);
}

    @Override
public void onResourceReady(@NonNull Drawable resource, @Nullable Transition<? super Drawable> transition) {
ImageView imageView = getView();
imageView.setImageResource(0); // 로딩 이미지가 보여지고 있을 때 scaleType이 바뀌면서 이미지가 덜그럭 거리는 이슈 방어
imageView.setScaleType(mReadyScaleType);
super.onResourceReady(resource, transition);
}

@NonNull
public static ScaleLoadingImageViewTarget loadingCenter(ImageView imageView) {
return new ScaleLoadingImageViewTarget(imageView, ImageView.ScaleType.CENTER);
}
}

loadingCenter 메소드를 통해 위에 새롭게 정의된 Target에 접근할 수 있도록 한다.

1
2
3
4
5
Glide.with(context)
.load(url)
.listener(listener)
.apply(options)
.into(ScaleLoadingImageViewTarget.loadingCenter(imageView));

참고링크

그러나 이 방식을 적용한 ImageView가 담긴 RecyclerView를 구현하게 되면, RecyclerView 각 아이템이 재사용되면서 ScaleLoadingImageViewTarget의 생성자에서 호출하는 view.getScaleType()의 값이 기대한 대로 나오지 않는 경우가 있어 문제가 될 수 있다.

따라서 이를 아래와 같이 view의 ScaleType을 명시적으로 받도록 변경하였다.

1
2
3
4
5
Glide.with(context)
.load(url)
.listener(listener)
.apply(options)
.into(ScaleLoadingImageViewTarget.loadingCenter(imageView, ScaleType.FIT_CENTER));
1
2
3
4
5
6
7
8
9
10
public ScaleLoadingImageViewTarget(ImageView view, ImageView.ScaleType originScaleType, ImageView.ScaleType loadingScaleType) {
super(view);
mLoadingScaleType = loadingScaleType;
mReadyScaleType = originScaleType;
}

@NonNull
public static ScaleLoadingImageViewTarget loadingCenter(ImageView imageView, ImageView.ScaleType originScaleType) {
return new ScaleLoadingImageViewTarget(imageView, originScaleType, ImageView.ScaleType.CENTER);
}

[Android] 상하 스크롤 중 내부 RecyclerView의 좌우 스크롤 유지

1. 상황

현재 한 Activity가 상하 스크롤이 되는 RecyclerView로 구성되어 있고, 그 아이템 중 하나가 좌우 스크롤 RecyclerView를 가지고 있다.

Activity 구성

RecyclerView는 View를 재사용하기 때문에 item1, item2, …의 뷰를 그릴 때 item1에서 사용한 뷰를 n번째 item에서 재사용할 수 있다. 따라서 다시 item1을 그릴 때 View를 초기화하게 되므로 item 내의 RecyclerView 스크롤이 초기화된다.

즉, item1의 좌우 스크롤을 움직인 후 아래로 스크롤링하고 item1 위치로 다시 돌아왔을 때 그 좌우 스크롤이 처음으로 되돌아가있는 이슈가 발견되었다.

2. 해결

1
2
3
4
val pos = (rv.layoutManager as LinearLayoutManager?)?.findFirstCompletelyVisibleItemPosition() ?: -1
if (pos != -1 && scrollInfo?.scrollPos != pos) {
scrollInfo?.scrollPos = pos
}

이렇게 하면 일단 현재 보고있는 item의 position을 얻어와 저장할 수 있다. 이 동작을 사용하여 아래와 같이 OnScrollListener에서 scrollPos와 scrollOffset을 저장한다.

scrollOffset은 scrollPos만으로는 해당 item의 맨 앞부분으로 스크롤이 움직이기 때문에 이를 현재 스크롤하던 위치로 옮겨주기 위함이다.

1
2
3
4
5
6
7
8
9
10
rv.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val pos = (rv.layoutManager as LinearLayoutManager?)?.findFirstCompletelyVisibleItemPosition() ?: -1
if (pos != -1 && scrollInfo?.scrollPos != pos) {
scrollInfo?.scrollPos = pos
}
scrollInfo?.scrollOffset = rv.computeHorizontalScrollOffset()
}
})

그리고 저장한 scrollOffset을 사용하여 스크롤을 유지할 수 있는 함수는 아래와 같이 구성하였다.

1
2
3
4
5
6
7
8
9
10
private fun keepScroll(scrollInfo: ScrollInfo) {
var offset = 0
if (scrollInfo.scrollPos < goodsList.size - 2) {
offset = scrollInfo.scrollOffset
while (offset > itemWidth) {
offset -= itemWidth
}
}
(rv.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(scrollInfo.scrollPos, offset)
}

3. 번외

scrollToPositionWithOffset이 position과 offset 값을 받는 걸 이용하여, position은 0으로 고정하고 offset을 rv.computeHorizontalScrollOffset()을 저장한 값으로 넘겨주면 될 거라고 생각했으나 기대한대로 동작하지 않았다.

1
(rv.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(0, offset)

[Android] Android 12 변경사항 일부 요약

이하는 보편적으로 크게 영향있을 것 같은 것만 추렸다. Android 12 업데이트 관련하여 따로 targetSDK를 바꾸어 대응하지 않았기 때문에 간략하게만 요약하였다.

Android Developer - 동작 변경사항: 모든 앱

신뢰할 수 없는 터치 이벤트가 차단됨

이 변경사항은 FLAG_NOT_TOUCHABLE 플래그 등을 사용하여 터치가 창을 통과할 수 있도록 하는 앱에 영향을 줍니다.

어떤 오브젝트가 화면을 가리고 있는데 그 아래에 위치된 오브젝트를 터치하고자 할 때 이 터치를 허용할지에 관한 내용이다..

동작 변경사항: Android 12를 타겟팅하는 앱에만 영향

앱이 Android 12를 타겟팅하고 인텐트 필터를 사용하는 활동이나 서비스, broadcast receiver를 포함하면 이러한 앱 구성요소의 android:exported 속성을 명시적으로 선언해야 합니다.

예를 들자면 아래와 같다.

1
2
3
4
5
6
<service android:name="com.example.app.backgroundService"
android:exported="false">
<intent-filter>
<action android:name="com.example.app.START_BACKGROUND" />
</intent-filter>
</service>

[Android] Android 11 대응 - 2. 범위 지정 저장소 적용

이 글은 실제로는 2021.01에 작성되어 일부 업데이트된 내용이 누락되었을 수 있음을 미리 밝힙니다.

1. 내부 저장소와 외부 저장소

내부 저장소에 저장된 파일은 애플리케이션 전용이며 다른 애플리케이션에서는 액세스 할 수 없다. 내부 저장소는 /data/data/com.example.testapp/ 이와 같은 경로를 말한다.

외부 저장소는 내부 저장소(UT) 또는 이동식 저장소의 어느 위치에나 있을 수 있다. 휴대전화의 OEM 및 Android OS 버전에 따라 달라질 수 있으므로 단말 기종마다 저장되는 위치가 다르다.

/sdcard 및 /storage/emulated/0 와 같은 형식의 패스는 외부 저장소를 가리킨다. 그러나 이것들은 /storage/sdcard0 대한 심볼릭 링크이기 때문에 (예전에는 된 것 같지만) 현재는 이와 같은 패스를 하드코딩하여 사용하지 않는 게 좋다.

2. 접근 권한

  • READ_EXTERNAL_STORAGE
  • WRITE_EXTERNAL_STORAGE

접근 권한은 위와 같이 잘 알려진 두 가지가 있으나 Android 11부터 WRITE_EXTERNAL_STORAGE만 권한허용을 해도 READ_EXTERNAL_STORAGE 권한은 자동으로 부여되도록 변경되었다.

Any app that declares the WRITE_EXTERNAL_STORAGE permission is implicitly granted this permission.
역> WRITE_EXTERNAL_STORAGE 권한을 허용한 앱은 내재적으로 READ_EXTERNAL_STORAGE 권한도 허용한다.

3. 안드로이드 P이하에서의 저장소 링크

  • 앱 데이터 폴더 : /storage/Android/data/[앱의 package name]/
  • 공용 폴더(DCIM, Pictures 등): /storage/[폴더 이름]

4. 안드로이드 Q이상에서의 범위 지정 저장소(Scoped Storage)

Android 10(API 수준 29) 이상을 타겟팅하는 앱에는 기본적으로 외부 저장소로 범위가 지정된 액세스 권한 또는 범위 지정 저장소가 부여됩니다. - 범위지정 저장소

Android 10에서는 선택적으로 범위지정 저장소를 사용하거나 사용하지 않을 수 있다.

Android 11(Q)이상부터는 강제로 바뀌는데, 범위지정 저장소는 아래와 같은 구조로 되어있다. 지칭하는 용어도 위치, 접근하는 방법(코드)도 완전히 바뀌기 때문에 Android 11 업데이트에서 가장 대응하기 난감했던 부분이다.

  • 앱 데이터 폴더(App specific directory)
  • 미디어 파일들(MediaStore)
  • 공용 파일들(Storage Access Framework)

5. 관련 이슈

java.io.FileNotFoundException: /storage/emulated/0/mediapicker/images/c.png: open failed: ENOENT (No such file or directory)

기존에 사용하던 파일 접근 로직을 사용하는 중이라면 위와 같이 FileNotFoundException ENOENT error를 보게 된다. 원래 읽어오던 파일 또는 파일의 위치에 접근할 수 없어서 “찾을 수 없다”는 에러를 띄우는 것이다.

6. 변경 사항

Android Developer - Scoped Storage

  • /sdcard 의 접근이 불가능해진다.
  • 외부 저장소의 최상위 경로를 반환하는 Enviroment.getExternalStorageDirectory()는 Android 10(API 29)부터 deprecated 되었고, 위에서 말한 것처럼 Android 11에서는 더이상 사용할 수 없다.

7. Android 10 호환성을 위해 유지할 것

Android 11에서는 해당 속성은 무시된다. 하지만 Android 10 호환성을 위해 requestLegacyExternalStorage=true 값을 유지해야 한다.

Android Developer - 범위 지정 저장소를 일시적으로 선택 해제

8. 문제가 될만 한 호출 메소드

다음 두 개의 메소드는 Android 10(API 29)부터 deprecated 되니 Context#getExternalFilesDir() 또는 MediaStore, Intent#ACTION_OPEN_DOCUMENT로 대체해야 한다.

Android Developer

(1) getExternalStorageDirectory

1
public static File Environment#getExternalStorageDirectory()

Context#getExternalFilesDir()로 대체한다.

이 방식을 사용하는 건 다음 두 개를 만족하는 경우이다.

  1. 앱이 삭제되면 같이 지워져도 된다.
  2. 외부에서 사용할 수 있도록 공공 저장소에 저장해도 된다.

(2) getExternalStoragePublicDirectory

1
public static File Environment#getExternalStoragePublicDirectory(String type)

이는 사진 및 영화와 같이 잘 알려진 파일 유형을 저장하기 위한 중앙 집중식 장소이다. 이 디렉토리와 내용은 앱을 제거할 때 삭제되지 않는다.(ex 위치: DCIM)

MediaStore 또는 Intent#ACTION_OPEN_DOCUMENT 사용하여 대체

ACTION_OPEN_DOCUMENT는 파일을 선택할 수 있는 파일 탐색기가 뜨도록 유도할 수 있으며, MediaStore를 사용한 예는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
SimpleDateFormat format1 = new SimpleDateFormat("yyyy-MM-dd_HH:mm:ss");
String fileName = format1.format(System.currentTimeMillis()) + ".jpg";

ContentValues values = new ContentValues();
// DCIM/ 또는 Pictures/ 이외의 장소에 접근하려고 하면 오류
values.put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/" + CommConstants.FOLDER_DIRECTORY);
values.put(MediaStore.Images.Media.DISPLAY_NAME, fileName);
values.put(MediaStore.Images.Media.MIME_TYPE, "image/*");
values.put(MediaStore.Images.Media.IS_PENDING, 1); // 파일을 write중이라면 다른곳에서 데이터요구를 무시

ContentResolver contentResolver = getContentResolver();
Uri collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
Uri item = contentResolver.insert(collection, values);
ParcelFileDescriptor imageFile = null;
try {
// Uri(item)의 위치에 파일을 생성해준다.
imageFile = contentResolver.openFileDescriptor(item, "w", null);
if (imageFile != null) {
InputStream inputStream = getImageInputStream(bitmap);
byte[] strToByte = getBytes(inputStream);
FileOutputStream fos = new FileOutputStream(imageFile.getFileDescriptor());
fos.write(strToByte);
fos.close();
inputStream.close();
imageFile.close();
contentResolver.update(item, values, null, null);
}
} catch (IOException e) {
e.printStackTrace();
}

if (imageFile != null) {
values.clear();
values.put(MediaStore.Images.Media.IS_PENDING, 0); // 파일을 모두 write하고 다른곳에서 사용할 수 있도록 0으로 업데이트
contentResolver.update(item, values, null, null);
}
}

(3) MediaStore.MediaColumns.DATA 칼럼 접근 불가

query issue: invalid column latitude

MediaStore.MediaColumns.DATA 칼럼이 android 10부터 deprecated되어 접근할 수 없어서 발생하는 이슈이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static String getRealPathFromURI(Context context, Uri contentUri) throws SecurityException {
String res = null;
String[] proj = {MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.DISPLAY_NAME};
Cursor cursor = context.getContentResolver().query(contentUri, proj, null, null, null);
if (cursor != null && cursor.moveToFirst()) {
int columnIndex = (contentUri.toString().startsWith("content://com.google.android.gallery3d")) ?
cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME) :
cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA);
res = cursor.getString(columnIndex);
cursor.close();
}
if (TextUtils.isEmpty(res)) {
res = contentUri.getPath();
}
return res;
}

따라서 이미지는 Q 미만일 때 위의 코드를 그대로 사용하면서, 비디오를 업로드할 경우는 아래의 코드를 통해 새로 카피본을 만들어 File을 받아 처리하도록 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static File getVideoFile(Context context, Uri contentUri) throws SecurityException {
ContentResolver contentResolver = context.getContentResolver();
String filePath = context.getApplicationInfo().dataDir + "/" + UPLOAD_FILE_NAME_ORIGIN_VIDEO;
File file = new File(filePath);
if (file.exists()) {
return file;
}
try {
InputStream inputStream = contentResolver.openInputStream(contentUri);
OutputStream outputStream = new FileOutputStream(file);
byte[] buf = new byte[1024];
int len = 0;
while((len = inputStream.read(buf)) > 0) {
outputStream.write(buf, 0, len);
}
outputStream.close();
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
return file;
}

9. 파일 저장 위치

이미지의 경우, 사진 및 스크린샷을 포함하며 DCIM/ 및 Pictures/ 디렉터리에 저장된다. 시스템은 이러한 파일을 MediaStore.Images 테이블에 추가한다. 즉, 파일 탐색기에서 Images 영역에 가면 저장한 파일을 확인할 수 있다.

만약 DCIM/ 또는 Pictures/ 이외의 장소에 접근하려고 하면 아래와 같은 에러가 뜬다.

Caused by: java.lang.IllegalArgumentException: Primary directory TestDir not allowed for content://media/external_primary/images/media; allowed directories are [DCIM, Pictures]

파일 탐색기 > Images

이미지 외의 파일 종류에 관해서는 Android Developer - 공유 저장소의 미디어 파일에 액세스를 참조할 것.

10. Android 10 애뮬레이터 테스트

그런데 이 설정을 하지 않아도 Android 11 애뮬레이터로 처음 실행했을 때 관련 이슈는 바로 재현 가능했으니 참고 하시기.

테스트를 위해 필요한 플래그는 다음의 두 가지이다.

  • DEFAULT_SCOPED_STORAGE(기본적으로 모든 앱에 사용 설정됨)
  • FORCE_ENABLE_SCOPED_STORAGE(기본적으로 모든 앱에 사용 중지됨)

FORCE_ENABLE_SCOPED_STORAGE 플래그를 설정하기 위해 시스템 > 고급 > 개발자 옵션 > 앱 호환성 변경사항 > 앱 > 에서 FORCE_ENABLE_SCOPED_STORAGE 항목을 찾아 on 시킨다.

개발자 옵션 > 앱 호환성 변경사항

[Android] Android 11 대응 - 1. 패키지 공개 상태

변경사항 확인해보는 방법

Android Developer

문제가 될만 한 호출 메소드

  • packageManager.getInstalledApplications() 또는 packageManager.getInstalledPackages()

  • packageManager.resolveActivity(intent, 0)

  • packageManager.queryIntentActivities(intent, flags)

  • packageManager.getPackageInfo("packageName", flags)

  • packageManager.getLaunchIntentForPackage(packageName)

방법 1. QUERY_ALL_PACKAGES

QUERY_ALL_PACKAGES 권한을 추가하면 모든 앱을 찾거나 실행할 수 있다. 이 퍼미션은 어떤 앱이든 받을 수 있는 Install permission이다.

1
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />

방법 2. <queries> 태그

gradle version 확인하기

queries 태그를 사용하기 위한 준비작업이다.

Android developer

좌측과 같은 버전을 사용하고 있다면 우측 이상에 해당하는 버전이 맞는지 확인해야 한다.

queries 태그 사용

이제 의존성을 가진 모든 앱의 패키지 정의를 queries 태그에 추가 적용한다.

Android11에서는 기본적으로 자신의 앱이 아닌, 다른 패키지를 찾거나 다른 패키지의 액티비티를 실행할 수 없다. 따라서 자신의 앱 동작에 의존적인 패키지가 있다면 AndroidManifest에 <queries> 태그로 필요한 패키지를 정의해야 한다.

다시 말해, 자신의 앱에서 다른 앱을 찾거나(탐색), 다른 앱을 실행하는 동작이 필요할 경우 그 앱의 패키지를 미리 자신의 앱에 정의해두어야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
<!-- manifest 태그에 package 속성을 정의하는 것은 optional -->
<manifest package="com.example.game">
<queries>
<package android:name="com.example.store" />
<package android:name="com.example.services" />
<intent>
<action android:name="android.intent.action.SEND" />
<data android:mimeType="text/plain" />
</intent>
</queries>
...
</manifest>

[Android] ViewPager.addOnPageChangeListener

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
loopViewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
override fun onPageSelected(position: Int) {
loopViewPager.currentItem = position
}
override fun onPageScrolled(position: Int, positionOffset: Float,
positionOffsetPixels: Int) {
  }
override fun onPageScrollStateChanged(state: Int) {
if (state == RecyclerView.SCROLL_STATE_DRAGGING) {
// 수동으로 스크롤 시에만 터치했을 때
} else if (state == RecyclerView.SCROLL_STATE_SETTLING){
// 스크롤이 움직인 후 픽스되는 순간
} else if (state == RecyclerView.SCROLL_STATE_IDLE) {
// 아무것도 수행하지 않을 때
// (즉, 스크롤이 움직이지 않고, 스크롤을 수동으로 잡고 있지 않음.)
}
}
})

onPageSelected

  • 페이지 인덱스가 변경되지 않는다면 이 메소드는 호출되지 않는다. (수동으로 스크롤 하다가 뗐을 때 재현가능)
  • position: 스크롤 시 선택된 페이지의 인덱스

onPageScrolled

  • 사용자의 터치 스크롤 & smooth 스크롤 시에 호출된다.
  • position: 링크에 따르면, “SETTLE 상태에서는 대상 페이지가 넘어오고, DRAGGING 상태에서는 출발 페이지가 넘어온다”고 한다. 따라서 이를 활용하기는 어려울 것 같아 사용하지 않음.
  • positionOffset: 0.0 ~ 1 사이의 값. 왼쪽으로 스크롤 시 1 -> 0.0으로 감소하며, 오른쪽으로 스크롤 시 0.0 -> 1로 증가한다.

onPageScrollStateChanged

  • 페이지 인덱스가 변경될 경우, 변경되지 않을 경우 포함
  • 상태가 변경될 경우 한 번씩 호출된다.

이벤트 전달 순서

  • 수동으로 스크롤 시 페이지를 변경했을 때 순서 SCROLL_STATE_DRAGGING -> SCROLL_STATE_SETTLING -> onPageSelected SCROLL_STATE_IDLE
  • loopViewPager.setCurrentItem으로 스크롤될 경우 순서 SCROLL_STATE_SETTLING -> onPageSelected -> SCROLL_STATE_IDLE
  • 수동으로 스크롤 시 페이지를 변경하지 않았을 경우 순서 SCROLL_STATE_DRAGGING -> SCROLL_STATE_SETTLING -> SCROLL_STATE_IDLE

[Android] Parcelable: Java와 Kotlin 구현 차이

Parcelable을 상속받아 클래스 생성할 경우 CREATOR의 정의

Parcelable protocol requires a Parcelable.Creator object called CREATOR

1
2
3
4
5
6
7
8
9
10
11
public static final Creator<DataClass> CREATOR = new Creator<DataClass>() {
@Override
public SearchInfo createFromParcel(Parcel parcel) {
return new DataClass(parcel);
}

@Override
public DataClass[] newArray(int i) {
return new DataClass[i];
}
};

이걸 코틀린 코드로 변환하면 다음과 같이 만들 수 있으나-

1
2
3
4
5
6
7
8
9
10
@JvmField // 프로젝트에 자바 클래스도 존재하는 경우 JvmField 어노테이션 누락에 주의할 것 
val CREATOR: Parcelable.Creator<DataClass> = object : Parcelable.Creator<DataClass> {
override fun createFromParcel(parcel: Parcel): DataClass {
return DataClass(parcel)
}

override fun newArray(i: Int): Array<DataClass?> {
return arrayOfNulls<DataClass?>(i)
}
}

[코틀린] Parcelable 구현

-그러나 코틀린에서는 Parcelable 구현을 위해 @Parcelize 어노테이션을 제공하고 있기 때문에 꼭 필요한 경우가 아니면 자바와 같이 CREATOR를 만들 필요는 없다.

코틀린에서 구현한 Parcelable을 상속받은 Data Class는 다음과 같다. 이 코드는 이 자체만으로도 내부에서 CREATOR 기능을 수행한다.

1
2
3
4
5
6
7
8
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import kotlinx.android.parcel.Parcelize

@Parcelize
data class ParcelData(@SerializedName("A") var a: String? = "",
@SerializedName("B") var b: String? = "",
@SerializedName("C") var c: ArrayList<AnyData>? = arrayListOf()): Parcelable

참고를 위해 Parcelize 어노테이션에 작성된 주석의 일부를 가져왔다.

Instructs the Kotlin compiler to generate writeToParcel(), describeContents()[android.os.Parcelable] methods, as well as a CREATOR factory class automatically.

이는 자바 코드(또는 코틀린 코드) 상에서 putExtra를 통해 전달할 수 있다.

1
2
ParcelData p = new ParcelData();
intent.putExtra("extra", p);

그리고 intent를 통해 이동한 액티비티에서는 getParcelableExtra로 데이터를 받아올 수 있다.

[Android] 다른 앱 위에 그리기 권한: TYPE_APPLICATION_OVERLAY

[다른 앱 위의 그리기]는 폰 설정 > 애플리케이션 > 앱 > 고급 [다른 앱 위에 표시되는 앱]에서 권한 설정할 수 있다.

퍼미션

[다른 앱 위에 그리기] 설정을 추가하려면 Manifest에 이를 사용하겠다고 퍼미션을 추가해야하는데,

1
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>

권한: 다른 앱 위에 그리기(Draw over other apps / Appear on top)

위 퍼미션을 추가하면 앱 설정(설정>애플리케이션>해당 앱)에 [다른 앱 위에 그리기] 정보가 뜬다. (안드로이드 버전마다 언어:영어일 때의 표시나 노출 위치는 다를 수 있다.)

중요한 것은 [다른 앱 위에 그리기]의 기본값이 버전마다 다르다는 건데,

  • API level 23(Android 6.0 Marshmallow) 미만에서는 true
  • 그 이상에서는 false

앱스토어에서 설치할 경우 기본적으로 위와 같이 설정되어 유저의 단말에 설치됨을 숙지하여 기능구현 프로세스를 짜야한다.

버전 체크 방법

[다른 앱 위에 그리기] 기능이 필요할 경우 API level 23 이상에서는 버전 체크 처리를 해주어야한다.

1
2
3
4
5
6
7
/* REQ_CODE_OVERLAY_PERMISSION는 임의로 정한 상수
onActivityResult(int requestCode, int resultCode, Intent data)에서 requestCode로 받을 때 사용함 */
@TargetApi(Build.VERSION_CODES.M)
private static void onObtainingPermissionOverlayWindow(Context context) {
Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + context.getPackageName());
((Activity) context).startActivityForResult(intent, REQ_CODE_OVERLAY_PERMISSION);
}

덧붙여 [다른 앱 위에 그리기] 설정값이 true인지 확인하는 방법은 다음과 같다.

1
2
3
4
public static boolean alertPermissionCheck(Context context) {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
&& !Settings.canDrawOverlays(context);
}

주의: deprecated 된 WindowManager.LayoutParams의 플래그

  • TYPE_PHONE
  • TYPE_PRIORITY_PHONE
  • TYPE_SYSTEM_ALERT
  • TYPE_SYSTEM_ERROR
  • TYPE_SYSTEM_OVERLAY
  • TYPE_TOAST

위에 나열된 타입은 API level 26(Android 8.0 Oreo)에 deprecated 되어 아래와 같이 Android Developer 에서는 이것들 대신 TYPE_APPLICATION_OVERLAY를 쓰도록 가이드 하고 있다.

1
2
3
4
5
6
7
val param: WindowManager.LayoutParams
val flag = if (Build.VERSION.SDK_INT >= Build.Version_CODES.O) {
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
}
param = WindowManager.LayoutParams(width,
WindowManager.LayoutParams.WRAP_CONTENT,
flag, ...);

[Android] 메모리 캐시와 디스크 캐시

브라우저에서의 메모리/디스크 캐시

메모리 캐시 vs 디스크 캐시 :: 마이구미 (정리잘되어있음)

아래는 해당 링크의 요약본이다.

브라우저 측면이 아닌 하드웨어 측면에서 보면,

  • 관련설명 잘 되어있는 추천영상: [bRd 3D] CPU는 어떻게 작동할까? 유투브링크
  • 디스크 캐시는 하드디스크에 접근하는 시간을 개선하기 위해 RAM에 저장하는 기법이다.
  • 캐시 메모리는 램에 접근하지 않고 더 빠른 시간으로 접근할 수 있는 CPU 칩 안에 있는 작지만 빠른 메모리이다.(L1, L2, L3)

브라우저 측면에서 보면,

기본적으로 캐시 데이터는 하드디스크에 저장되는데(즉, 디스크 캐시), 재사용 및 I/O 최소화 등의 이점이 있으나 더 빠른 처리를 위해 I/O 없이 웹코어 내부로 바로 접근하여 데이터를 가져오는 메모리 캐시가 존재한다.

앱, 비트맵 캐싱

비트맵 캐싱은 LruCache를 사용하는 대표적인 예다.

디스크 캐시 및 메모리 캐시에 대해 구분하고자 한 계기는 Glide다. Glide에서는 비트맵 풀 설정을 재정의할 때 다음과 같이 사용한다.

1
2
int bitmapPoolSizeBytes = 1024 * 1024 * 30; // 30mb
builder.setBitmapPool(new LruBitmapPool(bitmapPoolSizeBytes));

(참고로 기존에 사용하던 UIL의 설정 중 하나는 .memoryCache(new LruMemoryCache(2 * 1024 * 1024))였다.)

또한, 메모리 캐시를 사용하지 않거나 디스크 캐시를 어떻게 사용할지 Strategy(전략)을 설정할 수 있도록 하였다.

1
2
.skipMemoryCache(true) // default is false
.diskCacheStrategy(DiskCacheStrategy.NONE) // default is AUTO

그렇다면 왜 이 둘은 나뉘어져 있고, 왜 Lru를 사용할까.

비트맵 캐싱 측면에서 보는 메모리 캐시와 디스크 캐시

메모리 캐시는 “중요한 애플리케이션 메모리”를 사용하는 대신 비트맵에 빠르게 액세스할 수 있다. (인용 출처: 공식문서)

주석 > 링크 “중요한 애플리케이션 메모리”는 View > Tool Windows > Device File Explorer 로 들어가서 data/data 하위 폴더에서 확인할 수 있다.

하지만 애플리케이션이 종료되면 메모리 캐시는 사라지기 때문에, 일부 데이터는 “유지”될 필요가 있다.

이러한 경우 디스크 캐시를 사용하여 처리된 비트맵을 “유지”하고 메모리 캐시에서 이미지가 더 이상 사용 가능하지 않을 때 로드 시간을 줄일 수 있습니다.

결론

즉, 메모리 캐시는 빠르다. 앱 자체에서 가장 빠르게 도달할 수 있는 메모리에 저장되기 때문이다. 하지만 그 용량이 작아 이미지 로딩이 많은 경우 문제가 될 수 있으므로 디스크 캐시를 함께 사용하게된다.

디스크 캐시는 메모리 캐시에 비해서는 느리다. 따라서 여기에서 이미지를 불러올 경우 백그라운드에서 로딩이 되도록 비동기 처리를 함께 해줘야하는 대신 용량이 크다.

[Android / Glide] 이미지 URL을 읽어 JPG 포맷파일로 변환하기

1. remote url의 이미지를 비트맵 형식으로 가져온다.

1
2
3
4
5
6
7
fun loadBitmapImage(context: Context, url: String?, listener: RequestListener<Bitmap?>?) {
GlideApp.with(context)
.asBitmap()
.load(url)
.listener(listener)
.preload() //submit()으로 하면 Activity destroy 시점에서 오류
}

Glide를 사용해 listener를 통해 onResourceReady에서 비트맵 리소스를 받아 처리한다.

Glide를 쓰지 않고 가령, 갤러리에 저장된 이미지를 가져와 3번으로 넘어갈 경우, imageView에 비트맵을 저장하게 되는데, 이때 Bitmap의 recycle state를 관리해줘야할 수 있다. (참고: Glide에서 비트맵 이미지가 GC되는 과정)
이런 이유로 Android Developer 공식 문서에서는 대부분의 경우 Glide 라이브러리를 사용하여 앱에서 비트맵을 가져오고 디코딩하고 표시하는 것을 추천한다고 나와있다.

2. 비트맵을 JPG 포맷으로 압축, 저장한다.

이제 받아온 비트맵을 remoteTemp.jpg 라는 이름의 파일에 JPG 포맷으로 저장할 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mRemoteImageUri = Uri.fromFile(getReviewTempImageFile(context
, "remoteTemp.jpg"));
File bitmapFile = new File(mRemoteImageUri.getPath());

try {
FileOutputStream outputStream = new FileOutputStream(bitmapFile);
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream);

outputStream.flush();
outputStream.close();

setPickedImage(mRemoteImageUri);
} catch (Exception e) {
e.printStackTrace();
}

먼저 저장할 파일의 Uri를 만들었다. Uri를 생성하는 이유는 3번에서 파일의 사이즈를 얻어오기 위함이다. 자세히는 해당 번호 참조.

compress의 두 번째 인자값은 CompressFormat에 대한 압축률이며 0(minimum) ~ 100(maximum)으로 설정할 수 있다.

bitmap.compress(CompressFormat.PNG, 0, filestream)
위와 같이 PNG로 압축하여 출력할 경우 두 번째 인자값은 무시한다. PNG 파일의 특성이 손실률 없는 포맷에 해당하기 때문이다.

위의 코드에서는 FileOutputStream 객체를 사용하여 JPG로 압축한 비트맵이 파일에 저장되었으나 이를 파일로 저장하지 않고 처리하려면 ByteArrayOutputStream를 사용할 수 있다.

  • 코드 세부사항 파일에 저장하는 코드 상에서 사용한 getReviewTempImageFile 메소드는 아래와 같다. 여기서 ‣ 주의할 것.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    public static File getReviewTempImageFile(Context context, String imageName) {
    String imgDirName = "/TestDir";
    String dirPath = context.getExternalFilesDir(null) + imgDirName;
    File saveDir = new File(dirPath);
    if (!saveDir.exists()) {
    saveDir.mkdir();
    }

    String nomediaFilePath = dirPath + "/.nomedia";
    File nomediaFile = new File(nomediaFilePath);
    if (!nomediaFile.exists()) {
    try {
    nomediaFile.createNewFile();
    } catch (IOException e) {
    e.printStackTrace();
    }
    }

    String filePath = dirPath + "/" + imageName;
    return new File(filePath);
    }
    getExternalFilesDir()을 사용하기 때문에 미리 WRITE_EXTERNAL_STORAGE 퍼미션 처리를 해주어야 한다. crop을 수행하는 Activity에 진입하기 전에 체크해주는 게 가장 좋다. 해당 퍼미션을 체크하는 코드는 다음과 같다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    private void doImageSearch() {
        if (permissionCheck(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
            // start CropActivity
    } else {
    mRequestPermission = Manifest.permission.WRITE_EXTERNAL_STORAGE;
    requestPermission(this, PermissionConstants.REQUEST_STORAGE_CODE, Manifest.permission.WRITE_EXTERNAL_STORAGE);
    }
    }

    public static boolean permissionCheck(Context context, String permission) {
        return Build.VERSION.SDK_INT < Build.VERSION_CODES.M
            || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
            && ActivityCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED);
    }

    public static void requestPermission(Context context, int requestCode, String permission) {
        //shouldShowRequestPermissionRationale - 사용자가 권한 요청을 한번 거절 했을 경우 True
        if (ActivityCompat.shouldShowRequestPermissionRationale((Activity) context, permission)) {
            try {
    Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
    .setData(Uri.parse("package:" + context.getPackageName()));
    ((Activity) context).startActivityForResult(intent, PermissionConstants.REQ_CODE_REQUEST_SETTING);
    } catch (ActivityNotFoundException e) {}
        } else {
           // 처음 권한 요청 및 다시 보지 않기를 선택하였을 경우 false
            ActivityCompat.requestPermissions((Activity) context, new String[]{permission}, requestCode);
        }
    }

3. 이미지 파일을 적절한 사이즈로 변경하여 재저장

crop을 준비하기 위해서는 디바이스의 한 화면에 적절한 크기로 이미지를 노출해야한다. 위의 코드에서 setPickedImage 메소드가 이 기능을 담당한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
private void setPickedImage(@Nullable Uri rawBitmap) {
    mImageCaptureUri = Uri.fromFile(getReviewTempImageFile(context, "originTemp.jpg"));
    File bitmapFile = new File(mImageCaptureUri.getPath());
    try {
        Uri bitmapUri = mImageCaptureUri;
        if(rawBitmap != null)
            bitmapUri = rawBitmap;

        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inSampleSize = calculateBitmapSampleSize(this, bitmapUri);
        options.inJustDecodeBounds = false;

        InputStream is = getContentResolver().openInputStream(bitmapUri);
        Bitmap bitmap = BitmapFactory.decodeStream(is, null, options);

        if (bitmap == null) return;

        String uriPath = getRealPathFromURI(context, bitmapUri);
        if(TextUtils.isEmpty(uriPath))
            uriPath = bitmapUri.getPath();
        ExifInterface exif = new ExifInterface(uriPath);

        int exifOrientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
        int exifDegree = exifOrientationToDegrees(exifOrientation);
        bitmap = rotate(bitmap, exifDegree);
        FileOutputStream outputStream = new FileOutputStream(bitmapFile);
        bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream);

        outputStream.flush();
        outputStream.close();

        startCrop(bitmap);
    } catch (Exception e) {
        e.printStackTrace();
        finish();
    }
}

@Override
public void finish() {
    if(mImageCaptureUri != null)
        deleteTempImageFile(mImageCaptureUri);
    if(mRemoteImageUri != null)
        deleteTempImageFile(mRemoteImageUri);
    mCropImage.setImageBitmap(null); // clear bitmap in imageView

    super.finish();
    overridePendingTransition(0,0);
}