현재 한 Activity가 상하 스크롤이 되는 RecyclerView로 구성되어 있고, 그 아이템 중 하나가 좌우 스크롤 RecyclerView를 가지고 있다.
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의 맨 앞부분으로 스크롤이 움직이기 때문에 이를 현재 스크롤하던 위치로 옮겨주기 위함이다.
그리고 저장한 scrollOffset을 사용하여 스크롤을 유지할 수 있는 함수는 아래와 같이 구성하였다.
1 2 3 4 5 6 7 8 9 10
privatefunkeepScroll(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)
publicclassMain { publicstaticvoidmain(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"); } } } classData { 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()를 호출하기 때문에 두 방식은 같은 결과가 나온다. 참조 동등성을 비교하려면 === 또는 !== 연산자를 사용하면 된다.
funmain() { 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") } dataclassDataClass(val x: String = "x") classData(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
funmain() { 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인 경우에는 참조 동등성을 사용해 널과 비교하면 된다.
이카루스 테마에서는 사이드바에 광고를 넣을 수 있도록 제공되는데, 여기에 광고를 넣으려면 _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 clean 후 hexo 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) 콘텐츠 내에 광고 삽입
콘텐츠 내에 광고는 포스팅 내에 광고 삽입을 말한다. 이 경우 [광고 단위 기준]의 콘텐츠 내 타입으로 생성하였는데 사이드 메뉴에 넣을 디스플레이 타입과는 다르게 아래와 같이 생성된 스크립트 코드를 직접 포스팅 내에 삽입해주어야 한다.
이 글은 실제로는 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 권한도 허용한다.
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중이라면 다른곳에서 데이터요구를 무시
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
publicstatic 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을 받아 처리하도록 한다.
이미지의 경우, 사진 및 스크린샷을 포함하며 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]