I'm eating a book

안드로이드 프로그래밍 Next Step

  • Read started from 2022.06.18
  • 종이책 출간일: 2017.06.05
  • 저자: 노재춘

이게 2017년 책이라 오래 됐지만, dumpsys를 사용하는 예시를 좀 더 알고 싶어서 관련 책을 찾다가 서점에서 파라락 보고 훌쩍 사왔다. 그런데 생각지도 못하게 4, 5장에서 Context와 Task에 관해 좀 더 자세히 알게 되었다.

4장 Context

  • ContextWrapper/ContextImpl와 Activity/Service/Application에 관한 관계성
  • 사용 가능한 Context의 차이점: Activity, getBaseContext(), getApplicationContext()

5장 Task 및 dumsys

  • Task에 관한 설명은 기본적인 dumpsys 사용법 설명에서 얻어걸렸다. adb shell dumpsys activity a라는 명령어의 매우 기다란 결과 로그 중 기본적인 키워드를 설명해준다.
  • Task 속성: singleTop, singleTask 등 Manifest의 launchMode 속성 및 Intent 플러그 설명이 태스크에 관한 설명과 콜라보되어 동작이 자세히 나와 있다.

그 외 장

  • 위 명령어 말고도 dumpsys의 다양한 옵션에 대한 예시가 있어 어떤 경우에 어떤 옵션으로 명령어를 사용하면 될지 감을 잡는 데에 개인적으로 도움이 됐다.

실무에 바로 적용하는 안드로이드 프로그래밍

  • Read started from 2022.05.03
  • 종이책 출간일: 2021.03.25
  • 저자: 크리스틴 마시케노, 브라이언 가드너, 빌 필립스, 크리스 스튜어트

액티비티의 생명주기를 State를 기준으로 표현한 다이어그램은 기존 Android Developer에 있는 것보다 훨씬 보기 좋더라.

전체적으로 약간 딥한 부분까지 설명이 잘 되어있다.
예를 들어, onSaveInstanceState를 액티비티 레코드와 같이 설명해주거나, MVVM 뷰모델 vs Jetpack ViewModel의 차이를 설명해주는 등 상세하고 최신 내용이 잘 반영되어있다. 예제 코드가 모두 코틀린으로 되어있는 점도 그렇다.

안드로이드를 공부하는 사람이라면 기본서 다음 두 번째 책으로 적절할 것 같다. 안드로이드/자바/코틀린의 기초 문법을 설명해주지는 않아서.

이 책에는 외부 라이브러리를 사용한 예제는 테스팅 코드를 제외하고는 없다. 의존성 주입을 위한 Dagger2나 Lint 예제는 <아키텍처를 알아야 앱 개발이 보인다>(옥수환)에 있으니 참고 하시기.

코틀린 완벽 가이드

  • Read started from 2022.04.01
  • 종이책 출간일: 2022.03.18
  • 저자: 알렉세이 세두노프

코틀린에 대해 하나부터 열까지 상세한 코드 예제와 함께 설명이 수록되어있어 읽는데 막힘 없이 읽을 수 있어 코틀린 입문에 좋을 것으로 보인다.

구체적으로는 아래 항목이 있다.

  1. 데이터 타입 다루는 법
  2. 각종 클래스의 특징
  3. 제네릭
  4. 자바와의 상호 운용성 (아주 친절한 설명!)
  5. 동시성
  6. 테스팅

기본을 탄탄히 하는 데는 Kotlin Guide보다 한국어라는 점에서 책을 보는 게 더 나을 것 같다. 코틀린에 관해 원하는 것은 대부분 이 책에서 얻을 수 있으리라..

덧붙여 이 책은 Kotlin 1.6을 기준으로 쓰였으며 어느 하위 버전에서 어떤 업데이트가 있었는지도 중요한 부분은 설명이 되어있다.

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

[Kotlin] Ticker(티커) 모드 차이(TickerMode.FIXED_PERIOD, FIXED_DELAY)

개발 환경: Kotlin 1.6

coroutines 라이브러리에는 **티커(ticker)**라고 하는 특별한 랑데부 채널이 있다. 이 채널은 Unit 값을 계속 발생시키되 한 원소와 다음 원소의 발생 시점이 주어진 지연 시간만큼 떨어져 있는 스트림을 만든다.

여기서 랑데부 채널이란 내부 버퍼가 없어 이 채널에서의 send() 호출은 다른 어떤 코루틴이 receive()를 호출할 때까지 항상 일시 중단된다. 마찬가지로 receive() 호출은 다른 어떤 코루틴이 send()를 호출할 때까지 일시 중단되는 특성을 가진다.
즉, 랑데부 채널은 생산자와 소비자 코루틴이 교대로 활성화되도록 보장한다.

티커 채널을 만들려면 ticker()라는 함수를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun main() = runBlocking {
val ticker = ticker(100) // TickerMode.FIXED_PERIOD
println(withTimeoutOrNull(50) { ticker.receive() })
println(withTimeoutOrNull(60) { ticker.receive() })
delay(250)
println(withTimeoutOrNull(1) { ticker.receive() })
println(withTimeoutOrNull(60) { ticker.receive() })
println(withTimeoutOrNull(60) { ticker.receive() })
}
1
2
3
4
5
null
kotlin.Unit
kotlin.Unit
kotlin.Unit
null

TickerMode.FIXED_PERIOD

  1. 0ms ~ 50ms(타임아웃): 50ms(밀리초) 내에 티커 신호를 받으려고 시도하나, 티커 지연 시간이 100ms이므로 withTimeoutOtNull()은 신호를 받지 못하고 타임아웃이 걸려 널을 반환한다.

  2. 50ms ~ 100ms: 타임아웃이 1회 난 후 다음 60ms 안에 신호를 받으려고 시도한다. 그리고 이번에는 50 + 60ms가 100ms 보다 길기 때문에 결괏값을 얻는다. receive()가 호출되면 티커가 재개된다.

    2_1. 100ms ~ 350ms: 이때 소비자 코루틴이 약 250ms 동안 일시 중단된다. 일시 중단으로부터 100ms 후에 티커는 다른 신호를 보내고 신호가 수신될 때까지 일시 중단된다. 그리고 소비자 코루틴과 티커 코루틴 모두 150ms 동안 일시 중단 상태로 남는다.

  3. 350ms: 소비자 코루틴이 재개되고 신호를 요청하려고 시도한다. 신호가 이미 보내졌기 때문에 receive()는 즉시 결과를 반환한다.

    3_1. 이제 티커는 마지막 신호를 보내고 나서 얼마나 시간이 지났는지 검사하고(250ms), 지연 시간을 50ms로 줄인다.

  4. 350ms ~ 400ms: 소비자는 50ms 타임아웃 안에 신호를 받으려고 시도한다. 다음 신호가 50ms 이전에 보내졌기 때문에 이 시도는 거의 확실히 성공할 것이다.

  5. 400ms ~ 460ms(타임아웃): 마지막으로, 신호를 받으려는 receive() 호출이 거의 즉시 일어난다. 따라서 티커는 전체 지연 시간(100ms)를 다시 기다린다. 그 결과, 마지막 receive() 호출은 60ms 타임아웃 안에 티커로부터 신호를 받지 못하기 때문에 널을 받는다.

티커 모드를 FIXED_DELAY로 고정하면 결과가 다음과 같이 바뀐다.

1
2
3
4
5
null
kotlin.Unit
kotlin.Unit
null
kotlin.Unit

TickerMode.FIXED_DELAY

초반부는 앞의 예제와 비슷하게 진행된다. 하지만 250밀리초의 긴 지연 이후 소비자 코루틴이 재개될 때부터는 동작이 달라진다.

  1. 350ms: 소비자 코루틴이 재개되고 신호를 요청하려고 시도한다. 신호가 이미 보내졌기 때문에 receive()는 즉시 결과를 반환한다.

    3_1. receive()로 결과를 넘긴 시점에서 티커는 현재시간을 고려하지 않고 여기서부터 100ms를 다시 기다린다.

  2. 350ms ~ 410ms(타임아웃): 티커가 신호를 보내려면 40ms 남았으므로 널을 받는다.

  3. 410ms ~ 450ms: 3_1에서 티커가 재개된 시간으로부터 100ms가 지났으므로 결과를 무사히 반환받는다.

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

[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인 경우에는 참조 동등성을 사용해 널과 비교하면 된다.

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