[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)

자바와 코틀린에서의 동등성 비교(==, ===, equals())

  • 동일성(Identity): 참조 주소를 비교
  • 동등성(Equality): 내용 기반 비교

자바에서의 참조타입 동작

==와 != 연산자는 참조 동등성을 뜻하며, 내용을 기반으로 하는 동등성은 equals() 호출을 통해 구현한다.

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
import java.util.*;

public class Main
{
public static void main(String[] args) {
String[] arr1 = {"1", "2"};
String[] arr2 = {"1", "2"};
System.out.println("arr1 address: " + arr1 + " / arr2 address: "+ arr2);

if (arr1 == arr1) {
System.out.println("array Identity");
}
if (arr1.equals(arr2)) {
System.out.println("array Equality");
}

Data d1 = new Data("x");
Data d2 = new Data("y");
ArrayList<Data> list1 = new ArrayList();
list1.add(d1);
list1.add(d2);
ArrayList<Data> list2 = new ArrayList();
list2.add(d1);
list2.add(d2);
System.out.println("list1 address: " + list1 + " / list2 address: "+ list2);

if (list1 == list2) {
System.out.println("list Identity");
}
if (list1.equals(list2)) {
System.out.println("list Equality");
}
}
}

class Data
{
Data(String a) {
this.a = a;
}
private String a = null;
}

/** 출력
array Identity
list Equality
*/

참고로 new ArrayList()로 리스트 객체를 생성하는 게 아니라 list를 대입하게 되면 주소값이 동일해진다.

1
2
3
4
5
6
7
8
9
10
11
list1 = list2;
if (list1 == list2) {
System.out.println("list Identity");
}
if (list1.equals(list2)) {
System.out.println("list Equality");
}
/** 출력
list Identity
list Equality
*/

반면, 코틀린에서의 참조타입 동작

연산자 형태(== 또는 !=)로 호출하는 경우 equals()를 호출하기 때문에 두 방식은 같은 결과가 나온다.
참조 동등성을 비교하려면 === 또는 !== 연산자를 사용하면 된다.

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
fun main() {
val dataclass1: DataClass = DataClass()
val dataclass2: DataClass = DataClass()
val list1: List<DataClass> = listOf(dataclass1, dataclass2)
val list2: List<DataClass> = listOf(dataclass1, dataclass2)

if (list1 == list2) println("list used data class == Equality")
if (list1.equals(list2)) println("list used data class equals() Equality")
if (list1 === list2) println("list used data class Identity")

val data1: Data = Data()
val data2: Data = Data()
val list3: List<Data> = listOf(data1, data2)
val list4: List<Data> = listOf(data1, data2)

if (list3 == list4) println("list used class == Equality")
if (list3.equals(list4)) println("list used class equals() Equality")
if (list3 === list4) println("list used class Identity")
}

data class DataClass(val x: String = "x")
class Data(val y: String = "y")

/** 출력
list used data class == Equality
list used data class equals() Equality
list used class == Equality
list used class equals() Equality
*/

String 변수의 동작

변수 타입은 기본형(Primitive type)과 참조형(Reference type)이 있다. 그 중 String은 참조형이지만 기본형처럼 쓰이는데 위의 예제와 동작이 약간 다르다. 자바에서는 String str1 = "a"와 같이 일반적인 대입 방식으로 값을 초기화하면 String str2 = "a"처럼 같은 값(“a”)을 가진 변수는 같은 주소를 참조하게 된다. 따라서 아래 예제에서 ==와 equals 동작이 동일하게 true로 출력된다.
그리고 같은 값이라도 이 참조 주소를 다르게 설정하고자 new String()으로 객체를 생성하게 되는 경우 참조 동등성(==)을 비교하면 false가 반환된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
String str1 = "a";
String str2 = "a";

if (str1 == str2) System.out.println("string Identity");
if (str1.equals(str2)) System.out.println("string Equality");

String strNew1 = new String("b");
String strNew2 = new String("b");

if (strNew1 == strNew2) System.out.println("stringNew Identity");
if (strNew1.equals(strNew2)) System.out.println("stringNew Equality");

/** 출력
string Identity
string Equality
stringNew Equality
*/

코틀린에서는 new 키워드가 없어 다음 세 가지를 비교하면 이러하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
fun main() {
val str1: String = "a"
val str2: String = "a"

if (str1 == str2) println("string == Equality")
if (str1.equals(str2)) println("string equals() Equality")
if (str1 === str2) println("string Identity")
}
/** 출력
string == Equality
string equals() Equality
string Identity
*/

Nullable 값

자바에서는 NPE를 방지하기 위해 equals()에서도 수신 객체가 널인 경우 악명높은 NPE(Null Point Exception) 오류가 나기 때문에 null이 아님을 보장하기 위해 obj != null과 같이 조건문을 추가해 주어야 한다.

1
2
String obj = null;
if (obj.equals(something)) {} // NPE

반면, 코틀린에서는 ==와 != 두 연산자로 비교하는 값이 null이라도 오류가 나지 않는다. 연산자의 왼쪽 피연산자가 null인 경우에는 참조 동등성을 사용해 널과 비교하면 된다.

참고
알렉세이 세두노프 <코틀린 완벽 가이드>

기술 블로그 - AdSense 광고 적용

광고 게재 승인을 기다리는 와중에 기존에 사용하던 티스토리 블로그를 정리하려고 갔는데 우연치않게 [수익]란을 보게 되었다.

현재 운영중이진 않아서 3개월 내 공개 발행글이 없는데 AdSense의 승인이 지연되는 이유가 발행글이 10 아래여서 인 것 같아서 포스팅을 늘리면서 기다려보기로 함. 20개 포스팅이 넘으면 다시 확인해볼 예정.

Google AdSense: 15일 소요(2022.02.01 - 02.15 중 포스팅 16개 작성)

이카루스 테마에서는 사이드바에 광고를 넣을 수 있도록 제공되는데, 여기에 광고를 넣으려면 _config.icarus.yml에서 client_id와 slot_id를 기입해주면 된다.

아래 작업을 하려면 우선 Google AdSense 내에서 계정을 검토하는 과정을 거쳐야하는데, 이게 2주까지 걸릴 수도 있다고 하더니 내 경우 15일 걸렸다. 막 만든 사이트고(2월 1일 신청당시 포스팅 3개), 검토 요청을 한 2월 1-2일이 설연휴라 더 늦어졌을지도 모르겠다.

승인이 나면 승인되었다는 메일이 오고, Adsense 페이지에 들어가면 다음과 같이 [준비됨]이라고 바뀌어있다.

(1) ads.txt 삽입

아래 나오는대로 ads.txt 파일을 다운받아 루트(source/)에 넣고 hexo cleanhexo g 하면 public/ 하위에 생성된 것을 볼 수 있을 것이다.

이 문제는 확인되어 해결되는 데까지 며칠 걸리는 것 같지만 광고 노출 자체는 승인 후 하루 지나서 바로 되어 문제삼지 않았다. (저 빨간 “수익 손실 위험” 팝업은 광고가 노출 되어도 계속 떠있었다.)

(2) 신규 광고 단위 만들기: 사이드 메뉴에 광고 넣기

우선 AdSense 사이드 메뉴의 [개요]에서 상단 탭의 [광고 단위 기준]으로 이동하면 디스플레이/인피드/콘텐츠 내 광고 중 타입을 골라서 단위를 생성할 수 있다.

이카루스 테마에서는 _config.icarus.yml 설정파일에서 위젯으로 제공하기 때문에 여기에 넣을 client ID와 unit ID만 만들어주면 된다.

1
2
3
4
5
6
7
8
9
widgets:
-
# Where should the widget be placed, left sidebar or right sidebar
position: left
type: adsense
# AdSense client ID
client_id: 'ca-pub-0'
# AdSense AD unit ID
slot_id: '0000000000'

내 경우 디스플레이 타입으로 생성하여 만들어진 코드에서 data-ad-client, data-ad-slot 속성값을 각각 client_id, slot_id로 붙여넣었다.

(3) 콘텐츠 내에 광고 삽입

콘텐츠 내에 광고는 포스팅 내에 광고 삽입을 말한다. 이 경우 [광고 단위 기준]의 콘텐츠 내 타입으로 생성하였는데 사이드 메뉴에 넣을 디스플레이 타입과는 다르게 아래와 같이 생성된 스크립트 코드를 직접 포스팅 내에 삽입해주어야 한다.

광고 단위가 처음 만들어져 게시되는 경우 1시간 내외로 시간이 걸릴 수 있다.

콘텐츠 내 타입 광고는 아래와 같이 나타나게 된다!

-

[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 시킨다.

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