웹사이트

악기

교재

튜토리얼

워크샵

메모


February 1, 2022 #

  • Yamaha YTR-2320
  • Yamaha 11C4에서 14C4로 변경

마우스피스 변경한 기념으로 기록. 진작에 구입했었는데 이제야 바꿨다. 아직 음역 넓지 않아서 바꾸지 않아도 된다고 생각했는데 바꾸니까 전체적으로 톤이 더 고르게 나는 느낌.

스케일과 롱톤 위주 연습 루틴. 끝에 곡 연습 조금 하는 정도. 롱톤 연습을 하면 음이 확실히 안정적으로 나오지만 연습이 점점 지루하게 느껴진다. 연습은 주 3회, 회당 30분. 가끔은 그냥 이런 저런 곡 불러보고 그러면서 즐거움 챙긴다. 더 오래 연습하고 싶지만 30분 되어가면 지친 나머지 힘으로 마우스피스를 눌러 부는 기분이 들어서 시간을 꼭 지켜서 불고 있다.

호흡과 앙부쉬르에 관해서 이런 저런 글을 많이 읽었다. 연습하면서 느낀 점은 다 다른 얘긴데 다 맞는 말이다. 소리는 한 가지 이유로 나는 것이 아니니까 진단도 다르고 해결도 가지각색인 것이 당연한 모양.

요즘 가장 와닿는 조언은 힘을 빼고 편하게 불어야 한다는 말인데 정말 어렵다. 연습하면서 느낀 점은 이 얘기는 단순히 힘 빼라는 얘기가 아니고 그만큼 효율적으로 불어야 한다는 의미라는 점이다. 부는 동안 호흡이 모자라지 않고 앙부쉬르를 유지할 수 있는 근육이 잘 발달해야 최소한의 힘으로 소리를 낼 수 있다. 결과적으로 힘을 덜 쓰게 되는데 그 부분만 얘기하니까 약간 선문답 같기도.

Published on April 26, 2021

갑자기 턱이 붓고 아프기 시작했다. 별 일 아니라 생각하고 약먹고 잤는데 다음 날 얼굴이 두 배로 불어서 아내도 출근 못하고 나를 대리고 ER로 갔다. 가자마자 무엇이 필요한지 빠르게 말해주는 아내가 고마웠다. 전문가와 같이 살며 가족 중에 자문을 구할 수 있다니 감사할 뿐이다. ER 구석에 앉아 나는 내내 아파했고 아내는 옆을 지켜줬다.

IV 넣는 동안 어쩐 일인지 식은 땀이 흘렀다. CT도 처음으로 촬영했다. 감염이 된 어금니를 뽑고 염증이 나을 수 있도록 조치를 받았다. 약도 잔뜩 받았다. 나는 걱정하는 아내 옆에서 금니가 아깝다는 얘기나 했다. 침대에 누워서 그대로 잠들고 싶었지만 환자가 밀렸다며 찬바람이 들어오는 복도 의자에 앉아서 기다렸다. 풀려가는 마취에 점점 괴로웠고 어떻게 집에 왔는지도 모르겠고 빨리 샤워하고 침대에 누웠다.

의료인이 아니라서 어떤 원리인지는 잘 모르지만 염증이 빨리 낫도록 치아를 뽑은 자리에 공기가 공급될 수 있도록 빨대를 꽂았다. 처음엔 입을 벌리기 힘들 정도로 염증이 아픈 탓에, 지금은 빨대에 뭐가 들어가면 안된다는 말에 뭔가 씹어서 먹는 음식 대신 부드러운 음식만 먹고 있다. 먹는 양도 줄어서 살도 빠졌다. 안그래도 다이어트를 얘기하던 차였는데 이런 식으로 시작하게 될 줄 몰랐다. 가족이랑 앉아서 티비를 보면 온통 먹는 얘기 밖에 없어서 더 괴롭다.

그래도 모든게 감사했다. 그리고 아내에게 늘 미안하다.

준비물

스콘 8~10개 분량.

  • 셀프라이징 밀가루(self‑rising flour) 3컵
  • 버터 (차갑게, 큐브로 썰어서) 80g
  • 우유 1컵에서 1 1/4컵

셀프라이징 밀가루가 없다면 직접 만든다. 아래 비율로 섞으면 셀프라이징 밀가루 1컵과 같다.

  • 중력분(all purpose flour) 1컵
  • 베이킹 파우더 1 1/2 티스푼
  • 소금 1/4 티스푼

과정

  1. 밀가루를 체에 거른다.
  2. 버터를 넣고 밀가루와 잘 섞으며 버터를 모두 뭉갠다. 만져봐서 큰 버터 덩어리가 없을 정도.
  3. 가운데를 파서 우유를 1컵 부은 후 살살 반죽한다. 반죽이 안되면 우유를 조금씩 더 넣는다. 반죽을 많이하면 스콘이 딱딱해질 수 있어서 적당히 뭉칠 정도로만 한다.
  4. 배이킹시트를 깔고 밀가루를 조금 뿌린다.
  5. 한 주먹 정도 크기로 나눠서 1cm 간격으로 올린다. (지름 5cm 두께 2cm 정도..인데 취향 따라서)
  6. 스콘 반죽 위에도 밀가루를 체로 걸러 뿌린다.
  7. 200°C (390°F)로 예열한 오븐에 20~25분 정도 올린다.
  8. 잘 부풀고 원하는 색이 되었다면 꺼낸다.
  9. 잼이나 생크림을 곁들여 먹는다.

스콘 반죽

엄청 부풀지는 않아서 적당히 간격을 두면 된다.

스콘

완성!

공부

모든 학교 수업을 부지런히 따라가고 있지만 선형대수학에서 괴로워하고 있다. 과제 분량도 이전 수업과 다르게 너무 많은 데다 강의 노트도 엉망인 수업이라 수업 따라가는 일도 괴롭다. 특히 정리되지 않은 약어를 많이 쓰는데 space도 span도 sp로 쓴다거나 vector space를 vec sp, vecs, vs 식으로 맘 내키는 대로 하고. 어떻게든 시간을 많이 쓰면 되겠지 생각했는데 과제나 퀴즈에서 받는 점수를 보면 아무래도 빨리 철회하지 않은 나한테 화날 것만 같다.

처음엔 정원이 꽉 찬 수업이었는데 이제 나 포함 9명만 남았다. 물론 어렵고 모르는 것 배우는 일은 좋지만 쓰는 시간만큼 점수가 나오지 않으면 제대로 배우고 있는가 하는 의문이 생긴다. 생각할 수 있는 대안이라고는 다른 자료를 찾아서 더 본다든지 온라인 수업을 찾아 듣는다든지 하는 일인데 결국 시간을 더 쓰는 쪽 말고는 뾰족한 방법이 없다. 어떻게든 되겠지.

다른 수업은 다 괜찮다. 잘 따라가고 새로운 내용을 공부하고 과제에 잘 적용하고 있어서 점수도 잘 나온다. 이전까지 들었던 수학도 그랬는데 왜 이 수업만... 사실 ratemyprofessors 보면 듣지 말았어야 했는데. 이제 후회하면 뭐하나 과제하러 가야지.

사진 수업은 별도 강의는 없지만 매주 소개해주는 작가들 작품 보고 비슷한 작업을 만들어내는 과정을 반복하고 있다. 지금까지 portrait과 landscape 과제가 있었는데 다음 달부터 비평이 있을 예정이라 기대된다.

운동

월초에는 분명 열심히 하겠다고 하고는 생각보다 많이 못했다. 애플워치 어워드 받으려고 부지런히 숫자를 채웠는데 버그인지 인식이 안된 어워드가 있길래 김이 빠져서(?) 놀았다. 좋은 핑계였어. 시계 끼는 일에 아직도 버릇이 안생겨서, 다음 달엔 제대로 차고 다니고 운동도 꾸준히 하기로.

소셜 미디어

수학 수업에 스트레스를 많이 받아서 이것 저것 들어가서 읽고 보고 그랬다. 클럽하우스에도 가입하게 되었는데 밀도 낮은 대화를 듣는 일이 이렇게 낯선 일이 되었나 싶을 정도로 가만 듣기가 힘들었다.

코딩

학교 수업에서도 코딩은 잔잔하게 하는데 아직 크게 막히는 부분은 없이 잘 듣고 있다.

어디에 시간을 많이 쓰는지 측정하기 위해서 만든 timelog라는 앱이 있는데 어디 올리진 않았지만 그동안 부지런히 사용하고 있었다. 이 앱을 좀 더 편하게 만들어서 공개할까, 그 생각으로 자잘한 컴포넌트를 조금씩 다시 만들기 시작했는데 오랜만에 뭔가 만들고 싶은 것을 작업하니 너무 재밌다. 결과를 만드는 과정이 내 삶에 가장 큰 활력소인걸 또 느낀다.

유튜브 보면서 시간 보내는 일 대신에 책을 읽기로 했다. (오래 전부터 한 결심이지만.)

  • 일의 기쁨과 슬픔: 오래 전에 구입했지만 다 읽지 못하고 책장에 꽂혀 있었는데 잠 안오는 밤에 읽었다. 단편 하나 하나 흥미롭게 읽었고 '백한번째 이력서와 첫번째 출근길'과 '탐페레 공항'에 여운이 많이 남았다.
  • 오래 준비해온 대답: 전반부는 재미있었는데 뒤로 갈수록 잘 모르겠다. 여행자라서 타자화 하는 것은 당연한 부분이겠지만 읽고서 찝찝한 부분도 조금 있어서. 다른 시칠리아 여행책을 더 찾아서 보고싶다.

글쓰기

노트에 잠깐씩 쓰는 생각은 있지만 좀 더 시간을 할애해서 글을 쓰고 싶다.

그 외

  • COVID-19 백신 접종을 했다. 2차 접종은 다음 달 예정이다.
  • 차량 타이어를 교체했다.
  • 할 일이 많고 바쁘다고 해서 짧게 스트레스를 해소할 만한 것을 자꾸 찾는 것 같다. 할 일을 집중해서 끝내고 짧게 쓰는 시간도 큰 덩어리로 만들어서 여행을 간다든지 할 수 있으면 좋겠다.
  • 노트를 플래너처럼 쓰고 있는데 할 일 목록과 마감일이 혼재되어 있어서 복잡하다. 예전에 만든 양식은 둘을 잘 분리할 수 있어서 좋았는데. 노트에 직접 양식을 그려서 사용하다보니 그냥 간단한 양식으로만 쓰고 있었는데 3월에는 이전 양식처럼 작성해서 써봐야겠다.

다음 달에는,

  • 시간 관리: 중간고사 시즌이 돌아오고 있어서 절실하다
  • 운동하기: 3월 어워드 달성하기
  • 자투리 시간에 책 읽기: 3권 목표

시간이 좀 걸리긴 하지만 결과물이 너무 만족스러워서 고구마 한 박스를 순식간에... 혈당 올라가는 이야기 😓 이 방법으로 매우 촉촉한 군고구마를 먹을 수 있다. 으깨서 우유랑 섞어 먹어도 맛있다.

준비물: 통고구마, 에어프라이어

  1. 250°F 또는 110°C로 30분 돌린다.
  2. 320°F 또는 160°C로 30분 돌린다.
  3. 뒤집는다.
  4. 380°F 또는 190°C로 30분 돌린다.
  5. 맛있게 먹는다.

중간에 확인한다고 자른 고구마는 다 말라서 맛 없었다. 꼭 통째로 넣을 것.

(@cosmebox_bot 레시피)

올해는 매달 작게라도 변화를 적어보기로 했는데 벌써 1월이 끝났다. 열심히 지냈는데 아직도 부족한 기분만 든다.

목공

작년부터 목공을 해보고 싶어서 막연하게 유튜브만 봤는데 크리스마스 선물로 목공 세트를 받게 되었다. 그래서 연초에 부지런히 잘라서 foot stool을 만들어 선물했다. 이케아에서 산 가구를 조립하는 일도 재밌지만 직접 나무를 잘라서 만드는 과정은 또 다른 세상이었다. 만드는 과정에서 이케아 가구 절대 비싼 것 아닌걸 알았다. 글로 쓰면 자르고, 붙이고, 칠한다 정도로 요약할 수 있지만. 다음 프로젝트도 고민중이지만 언제 이사가게 될 지 모르는 상황이 되어서 짐을 당분간은 늘리지 않기로 했다. 공간이 필요한 취미는 역시 부동산 앞에서 쉽게 흔들린다. Steve Ramsey 영상이 많이 도움 되었다.

공부

학기가 시작됐다. 가장 많은 이수 학점을 신청한 학기라서 시작 전부터 긴장했지만 몇 과목은 주제가 익숙하다는 점에 아주 어렵지 않을 것이란 막연한 생각을 했다. 학교에서는 이수 학점으로 수업에 할애해야 하는 시간을 계산하는 방법을 제공하고는 있지만, 가끔 그 계산법을 초월하는 과목과 교수님을 만나게 된다. 이번 학기도 그랬다. 한편으로는 별로 시간 쓰지 않고도 점수 잘 받을 수 있던 과목도 있으니까 적당히 평균을 이루고 있다고 생각해야 하는데 내 욕심은 역시 그렇지 않다. 그런 과목을 마주하면 누가 이기나 보자고 고집을 부린다.

이번 학기도 결국 시간 관리가 가장 중요하다. 항상 쓰던 플래너가 있었는데 올해는 무선 수첩에 양식 없이 자유롭게 써보고 있다. 양식 없이 사용하면 시작과 끝을 명확하게 표시할 필요가 있는 것 같다. 양식이 있으면 영역의 범위가 내가 쓸 수 있는 총 가용 시간이라는 대략적인 감각이 생겨서 좋다. 지금처럼 할 일 목록처럼 길게 적다 보면 그런 감각 없이 영원히 끝나지 않는 목록을 붙들고 있는 기분이 든다. 적당히 덩어리를 만들어서 오늘 할 일을 명확히 표시할 필요가 있다.

이번 학기엔 사진 수업도 듣는다. 가장 경쟁 많았던 수업인데 수강 신청 우선권이 있어서 이름을 넣을 수 있었다. 아직은 학기 초라서 카메라 작동 기초부터 배우고 있지만 이후 수업에 많이 기대하고 있다.

운동

매년 운동을 해야겠다고 거창한 계획을 잡았었는데 올해는 애플워치 숫자라도 채운다는 작은 목표를 세웠다. 전혀 운동 안 하던 사람답게 이조차도 쉽지 않았다. 반려자님과 함께 신년 어워드를 받기 위해 7일 동안 꽉꽉 숫자를 채워냈다. 땅끄부부 영상으로 절반, Just Dance로 절반을 달성했다. 링피트는 먼지가 쌓이고 있지만, Just Dance 2021에 좋은 곡이 많다면서 또 은근슬쩍 구입했다. 박자 맞춰서 놀다보면 운동은 덤으로 되는 기분이라서 본격 운동하자 기분이 되어버리는 링피트보다는 쉽게 하게 된다.

애플워치 숫자 채우기 가장 힘든 것은 사실 시계를 하루종일 차고 있는 일이다. 최대한 헐렁하게 차고는 있지만, 여전히 거슬리는데 좀처럼 익숙해지지 않는다. 적당히 숫자가 채워지면 충전 핑계로 풀어서 자유를 만끽한다.

매월 어워드에 조금 욕심내기로 했다. 2월에도 꼭 챙겼으면 좋겠다.

커피

집에서 커피를 계속 내려서 마시고 있는데 처제네 다녀올 때마다 커피를 잔뜩 사 온다.

  • Mount Comfort Coffee: 텍사스에서는 코스트코에 파는데 큰 봉지인데도 벌써 다 먹고 2번째 사 왔다.
  • Merit Coffee 샌안토니오 로컬 커피인데 카페서 마시고 완전 반했다. Andino Leal, Dembi Udo Natural 두 종류 사봤는데 둘 다 좋다.

소셜 미디어

가만 스크롤 굴리고 있으면 모두가 200km 이상 속도로 달리고 있는걸 구경하는 기분이 들어서 올해는 줄이기로 했다. 너무나도 다양한 감정들이 쏟아지고 있는 공간이라서 그런지 휩쓸려서 우울한 기분이 들 때도 잦다. 물 마시러 간다든지 할 때 조금이라도 틈만 생기면 앱을 열었었는데 전화에서는 모두 지웠고 급한 일이 있을 때만 브라우저로 잠깐 접속하기로 했다. (그렇게 급한 일은 없었다.) 가끔은 읽고 싶은 충동보다 기록을 남기고 싶어서 로그인 생각이 간절해지지만 노트앱, 특히 Hitnote에 간단하게 쓰는 것으로 기분이 좀 풀렸다.

그냥 기분만 그런 것 같지만 조급한 마음도 많이 줄어든 것 같고 예전보다는 덜 산만한 것 같다. 좀 여유가 생기면 책을 읽는다든지 좀 더 느린 흐름에 익숙해지고 긴 호흡이 필요한 활동을 하고 싶다.

글쓰기

거의 안썼다.

코딩

과제 외에는 못했다. 이번 학기는 C++랑 파이썬 수업이 있다.

그 외

  • Onyx Boox Note Air: 안드로이드 기반 10.3인치 전자책. 생각보다 가격이 비싸서 한참 고민하다가 마련했다. 큰 화면으로 PDF를 조금 덜 피로한 기분으로 볼 수 있어서 좋다. 크기가 더 큰 전자책과 고민했는데 크롭 기능도 제공하고 있어서 읽는 데 불편함 없다.
  • 차를 움직이지 않고 오래 주차해둔 탓에 배터리가 방전되었는데 충전한다고 멀리 갔다가 타이어가 상했다. 인근에 해당 차종 타이어가 없어서 교체를 못하고 있다.

다음 달에는,

  • 할 일을 차분하게 쓰고 잘 처리하기: 일간 단위로 시작과 끝을 분명하게
  • 운동하기: 애플워치 잘 활용하기
  • 책 읽기: 교과 외 책 찾아서 하루 1페이지 읽기
  • 글쓰기: 일주일에 적어도 플래너 1페이지 글 작성하기

학기말 시험이 끝나고 그간 공부한 자료를 스토리지 박스에 넣는 과정이 반복되고 있다. 이런 작은 일이 그사이 받았던 스트레스를 조금은 평탄하게 해주지 않을까 하고, 배우면 배울수록 부족함을 더 확인했던 시간, 거기에 따라오던 수많은 상념도 담아서 보내버린다, 보내버리겠다는 생각으로 작은 의식을 치른다. 박스가 엄청 무거운 건 아니지만 그렇다고 가볍게 들리진 않는다. 그래도 뭘 하긴 했구나.

아무리 좋은 점수를 받아도 1년을 긴 학기 둘, 짧은 학기 하나로 보내면 내가 여기에서 시간을 이렇게 보내고 있는 것이 맞나 생각이 자연스럽게 든다. 남 생각 안 하고 내 삶만 보자, 내 성장만 보자, 어제의 나보다 오늘의 나를 생각하자면서도 어느 사이에 주변을 보고서는 나 자신을 평가하기 바빠진다. 잠깐 기분 전환한다고 소셜 미디어에 가까워질 때마다 결국 이런 비교와 평가가 나를 갉아먹는다. 내게 도움이 되는 부분과 빼앗아가는 것을 늘 저울질하면서, 로그아웃, 로그인, 로그아웃. 이런 지난한 싸움이 매일 로그인 화면 앞에서 반복된다. 체류 시간을 높이기 위해 디자인된 수많은 도구와의 싸움은 지루하게 끝이 나질 않는다. 어찌 됐든 자신을 깎는 고민을 안 하려면 결국 지금 앞에 있는 것만 보고 집중하는 것 말고는 큰 대안이 없다. 그런데도 왜 문제의 답이 가까이 있으면서도 나는 왜 그 답에 쉽게 설득되지 않을까. 꼭 멀리에서 들리는 큰 목소리만 답처럼 들리는 것일까. 아는 답을 듣기 위해서 또 로그인, 로그아웃.

이번에도 얇은 플래너 하나로 학기를 보냈다. 마음에 드는 적당한 규격과 양식의 플래너를 찾지 못했다. 연초에 한국서 큰마음 먹고 사 온 플래너도 결국 흐지부지됐다. 작년에 간단하게 만들었던 서식을 또 출력해서 작은 플래너를 만들었다. 내가 과목마다 얼마나 시간을 써야 하는지 명확하지 않은 학기 초에는 플래너에 많이 의존하게 된다. 학기가 절반쯤 지나면 플래너는 체크리스트 역할만 정도지 무슨 요일엔 수학이랑 물리, 무슨 요일에는 무엇, 대략적인 감각이 생기는데 그렇게 루틴에 익숙해지는 순간이 몰입을 돕는 것 같다. 주제에 흥미가 더 붙고 더 알고 싶어졌다. 가끔 달라지는 일정을 플래너에 적고 시간을 조정하다 보면 모든 걸 다 끝낸 것도 아닌데 만족감이 든다. 충분히 할 수 있다는 생각에 부담도 적고 적당히 유연한 서식도 한몫한다. 플래너에 적으면 어떻게든 결과를 볼 수 있다는 자신감도 생긴다. 단순한 시스템을 갖추고 유지하는 일은 코드 밖에서도 적용된다. 끝난 일은 색칠하고, 못 한 일은 긋고 옮긴다. 그렇게 날마다 펼쳐두던 플래너를 접어서 박스에 같이 넣으면 보람찬 기분과 함께 약간은 헛헛한 기분도 든다.

일상과는 거리가 먼 2020년이고 매일 현실감 없는 뉴스에 절망감을 느낀다. 주변은 건강해서 다행이라고 말하는 것도 너무 이기적이다 싶을 정도로 매일 올라가는 숫자들에 마음이 아리다. 복잡한 세상에 비춰보면 일상은 믿기 어려울 정도로 납작하고 단편적으로 변했다. 불안한 마음에 장 보러 가는 일을 최소로 줄였고 외식하는 일은 전혀 생각하지도 않는다. 그렇게 단단히 마음먹고 살다가도 운전하다가 창밖으로 마스크를 안 쓸 자유를 주장하는 사람들이 피켓을 들고 흔드는 모습을 보고는 망연해진다. 각자의 자리에서 각자 전쟁을 치르는 이 땅의 모습은 도무지 익숙해질 것 같지 않다. 나는 이런 사회에서 무엇을, 어떻게 기여할 수 있을까.

그런 욕심 내지는 바람을 갖고 있어서 그런지 누군가에게 도움이 되는 도구와 서비스를 만드는 일에서 효능감이 높다. 그래서 회사 생활에 만족도가 높았었고 공부를 계속 미루던 이유에도 한몫했었다. (다달이 들어오는 월급의 유혹도.) 학교에 다니게 되면서 수업 잘 듣고 과제 잘해서 지적 성장을 도모하고 그 와중에 학점도 잘 챙기면 그것으로도 충분하다고 생각하지만 내가 효능감을 느끼는 영역과는 조금 거리가 멀다. 되려 괜찮은 점수를 받아도 조금 부족하거나 실수한 부분에서 스트레스를 더 받는 편이다. 내가 이런 부분에서 스트레스받는다는 점을 몰랐던 것은 아니지만 그동안은 거의 잊다시피 하고 지내고 있었다. 학교 다니면서 다시 코피가 나기 시작했다. 어릴 때도 늘 하루걸러 코피가 나고 그랬는데 인제야 이게 스트레스 지표나 마찬가지였구나, 깨달았다. 이해가 안되는 내용을 이해하려고 시간을 쓰고, 코피가 나서 그걸 막고 있다 보면 자괴감 비슷한 것이 밀려와서 심란해진다. 내가 하고 싶은 일을 하려면 이 길을 어떻게든 걸어야 해, 행동으로 옮기는 일은 정말 쉽지 않다.

어떻게 만든 기회인데 최선을 다해야 한다는 사실을 알지만 그렇다고 그게 쉽게 되는 일은 아니다. 어떤 이유를 만들어서든 매몰되지 않도록 눈을 가리고 가볍게 지나야 한다는 점은 알지만 어렵다. 3학기가 지나고 나니 나름 시스템이 생겼는지 대략 어떻게 준비하고 공부하면 되는지 감각이 생겼다. 그리고 큰 덩어리를 잘게 쪼개서 조금씩 해결하고 성취에 기뻐하고 작은 보상을 계속 준비하는 것만 어려움을 덜어내는 방법인 것 같다. 어쩌면 당연한 말이고 듣기도 많이 들었지만 겪고 체득하기 전까지는 내 것이 아니란 걸 또 배우게 되었다. 아는 것을 실천으로 옮길 수 있는 것도 능력이고 연습을 통해 근육을 쌓아야만 써먹을 수 있다는 것. 부지런히 근육을 만들어야겠다.

비주얼 타이머는 방학이 될 때마다 작더라도 업데이트를 하고 있다. 처음 만들고 나서는 에너지를 너무 많이 쏟아서 기대보다 낮은 성장에 실망했는데 시간이 지나 약간 거리를 두고 보니 그럴 필요가 전혀 없었다. 학업에 집중하다 보니 이 프로젝트도 조금 더 관망할 수 있어서 마음이 아주 홀가분해졌다. 여전히 엄청나게 많은 사용자가 있는 것은 아니지만 꾸준하게 사용하는 분도 꽤 있고 앱이 좋다며 장문의 리뷰와 피드백을 보내주는 분도 있다. 내게는 직장 생활 당시의 감각을 깨워주는 느낌도 있는 데다 내가 원하는 것처럼 누군가에게 도움이 되고 있다는 점에서 자신감을 줍는다. 후원으로 받은 소중한 돈으로 내년 애플 개발자 프로그램 비용도 지출했다. 처음 계획했던 범위에서는 앱을 다 만들었기 때문에 무얼 어떻게 개선할지 방향이 고민이다. 안드로이드로도 출시해보고 싶어서 코틀린 강의도 틈틈이 봤는데 다음 업데이트에 안드로이드도 포함되면 좋겠다.

연초에 목표로 했던 것을 적어보면,

  • 책 읽기: 거의 꽝 (< 5권), 대신에 읽기 과제가 많은 수업을 여럿 들었으니까...
  • 운동량 늘리기: 꽝. 애플워치가 생긴 이후로 조금 하긴 했지만.
  • 회고 주기적으로 하기: 꽝. 과제에 치여서 글을 거의 못 씀.
  • 시간 관리하기: 조금 성공. 조금은 더 철저할 필요가 있지 않았나.

내년 내 모습을 생각해보면 여전히 학교에서 씨름하고 있을 예정이다. 수업도 좀 더 어려워질 예정이고 시간도 많이 쓸 일이 생겨서 긴장되지만 지금 해온 것만큼 해낼 수 있었으면 좋겠다. 일상도 빨리 되찾을 수 있으면 좋겠다. 운동이나 글 쓰기, 책 읽기는 매년 목표지만 이번에도 또 다이어리 앞 장에 적어본다. 가족과 함께 집에 있는 시간은 판데믹 탓에 많아졌지만 온라인으로 전환된 수업이 대중없이 시간을 쓰게 만들어서 몸만 같이 있고 정신은 저 멀리 떠나있던 적도 많았다. 밖에서는 추억을 만들기 어렵더라도 집에서 무엇이든 재미있는 일을 더 꾸며봐야겠다. 제주에 있는 가족들도 많이 보고 싶지만 한국에는 언제 가게 될지 몰라서 좀 아쉽다.

올해도 수고가 많았다. Stop and smell the roses 🌹🌹🌹.

Android Kotlin Fundamentals Course 코드랩 하면서 노트. Android Kotlin Fundamentals 부터.

Starter code

DevBytes starter.

/**
 * VideoHolder holds a list of Videos.
 *
 * This is to parse first level of our network result which looks like
 *
 * {
 *   "videos": []
 * }
 */
@JsonClass(generateAdapter = true)
data class NetworkVideoContainer(val videos: List<NetworkVideo>)

/**
 * Videos represent a devbyte that can be played.
 */
@JsonClass(generateAdapter = true)
data class NetworkVideo(
        val title: String,
        val description: String,
        val url: String,
        val updated: String,
        val thumbnail: String,
        val closedCaptions: String?)

/**
 * Convert Network results to database objects
 */
fun NetworkVideoContainer.asDomainModel(): List<DevByteVideo> {
    return videos.map {
        DevByteVideo(
                title = it.title,
                description = it.description,
                url = it.url,
                updated = it.updated,
                thumbnail = it.thumbnail)
    }
}

Add an offline cache using Room

Add a dependency.

// build.gradle (Module:app)
// Room dependency
def room_version = "2.1.0-alpha06"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"

Add a DB entity.

// DatabaseEntities.kt
@Entity
data class DatabaseVideo constructor(
        @PrimaryKey
        val url: String,
        val updated: String,
        val title: String,
        val description: String,
        val thumbnail: String
)

fun List<DatabaseVideo>.asDomainModel(): List<DevByteVideo> {
    return map {
        DevByteVideo(
                url = it.url,
                title = it.title,
                description = it.description,
                updated = it.updated,
                thumbnail = it.thumbnail
        )
    }
}

Update a data transfer object.

// DataTransferObjects.kt
/**
 * Convert Network results to database objects
 */
fun NetworkVideoContainer.asDomainModel(): List<DatabaseVideo> {
    return videos.map {
        DatabaseVideo(
                title = it.title,
                description = it.description,
                url = it.url,
                updated = it.updated,
                thumbnail = it.thumbnail)
    }
}

Add VideoDao.

// Room.kt
@Dao
interface VideoDao {
    @Query("select * from databasevideo")
    fun getVideos(): LiveData<List<DatabaseVideo>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertAll(videos: List<DatabaseVideo>)
}

@Database(entities = [DatabaseVideo::class], version = 1)
abstract class VideosDatabase : RoomDatabase() {
    abstract val videoDao: VideoDao
}

private lateinit var INSTANCE: VideosDatabase

fun getDatabase(context: Context): VideosDatabase {
    synchronized(VideosDatabase::class.java) {
        if (!::INSTANCE.isInitialized) {
            INSTANCE = Room.databaseBuilder(context.applicationContext,
                    VideosDatabase::class.java, "videos")
                    .build()
        }
    }
    return INSTANCE
}

Repository

A repository module handles data operations and allows you to use multiple backends.

// VideosRepository.kt
class VideosRepository(private val database: VideosDatabase) {
    val videos: LiveData<List<DevByteVideo>> = Transformations.map(database.videoDao.getVideos()) {
        it.asDomainModel()
    }

    suspend fun refreshVideos() {
        withContext(Dispatchers.IO) {
            Timber.d("Refresh videos is called");
            val playlist = DevByteNetwork.devbytes.getPlaylist()
            database.videoDao.insertAll(playlist.asDomainModel())
        }
    }
}

Update the view model to apply a new refresh strategy.

// DevByteViewModel.kt
class DevByteViewModel(application: Application) : AndroidViewModel(application) {
    // init the repository
    private val videosRepository = VideosRepository(getDatabase(application))

    // ...

    // Replace playlist
    val playlist = videosRepository.videos

    // ...

    init {
        refreshDataFromRepository()
    }

    /**
     * Refresh data from network and pass it via LiveData. Use a coroutine launch to get to
     * background thread.
     */
    private fun refreshDataFromRepository() {
        viewModelScope.launch {
            try {
                videosRepository.refreshVideos()
                _eventNetworkError.value = false
                _isNetworkErrorShown.value = false
            } catch (networkError: IOException) {
                // Show a Toast error message and hide the progress bar.
                if (playlist.value.isNullOrEmpty())
                    _eventNetworkError.value = true
            }
        }
    }
}

다음 챕터: Android Kotlin Fundamentals

Android Kotlin Fundamentals Course 코드랩 하면서 노트. Android Kotlin Fundamentals and detail views with internet data 부터.

Add "for sale" images to the overview

// MarsProperty.kt
data class MarsProperty(
  val id: String,
  @Json(name = "img_src") val imgSrcUrl: String,
  val type: String,
  val price: Double
) {
  val isRental
    get() = type == "rent"
}

Update grid_view_item.xml.

<layout ...>
  <FrameLayout
    android:layout_width="match_parent"
    android:layout_height="170dp">
    <ImageView
      android:id="@+id/mars_image"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:adjustViewBounds="true"
      android:padding="2dp"
      android:scaleType="centerCrop"
      app:imageUrl="@{property.imgSrcUrl}"
      tools:src="@tools:sample/backgrounds/scenic" />
    <ImageView
      android:id="@+id/mars_property_type"
      android:layout_width="wrap_content"
      android:layout_height="45dp"
      android:layout_gravity="bottom|end"
      android:adjustViewBounds="true"
      android:padding="5dp"
      android:scaleType="fitCenter"
      android:src="@drawable/ic_for_sale_outline"
      android:visibility="@{property.rental ? View.GONE : View.VISIBLE}"
      tools:src="@drawable/ic_for_sale_outline" />
  </FrameLayout>
  <data>
    <import type="android.view.View" />
    <variable
      name="property"
      type="com.example.android.marsrealestate.network.MarsProperty" />
  </data>
</layout>

Filter the results

The URL will be:

// https://android-kotlin-fun-mars-server.appspot.com/realestate?filter=buy

Update the Mars API service.

// MarsApiService.kt
enum class MarsApiFilter(val value: String) {
  SHOW_RENT("rent"),
  SHOW_BUY("buy"),
  SHOW_ALL("all")
}
// ...
interface MarsApiService {
  @GET("realestate")
  suspend fun getProperties(@Query("filter") type: String):
    List<MarsProperty>
}

Update the view model.

// OverviewViewModel.kt
class OverviewViewModel : ViewModel() {
  // ...
  init {
    getMarsRealEstateProperties(MarsApiFilter.SHOW_ALL)
  }

  fun updateFilter(filter: MarsApiFilter) {
    getMarsRealEstateProperties(filter)
  }

  private fun getMarsRealEstateProperties(filter: MarsApiFilter) {
    viewModelScope.launch {
      _status.value = MarsApiStatus.LOADING
      try {
        _properties.value = MarsApi.retrofitService.getProperties(filter.value)
        _status.value = MarsApiStatus.DONE

      } catch (e: Exception) {
        _status.value = MarsApiStatus.ERROR
        _properties.value = ArrayList()
      }
    }
  }
}
<!-- overflow_menu.xml -->
<menu ...>
  <item
    android:id="@+id/show_all_menu"
    android:title="@string/show_all" />
  <item
    android:id="@+id/show_rent_menu"
    android:title="@string/show_rent" />
  <item
    android:id="@+id/show_buy_menu"
    android:title="@string/show_buy" />
</menu>

Then, update the view model when user choose the menu.

// OverviewFragment.kt
class OverviewFragment : Fragment() {
  // ...
  override fun onOptionsItemSelected(item: MenuItem): Boolean {
    viewModel.updateFilter(
      when (item.itemId) {
        R.id.show_rent_menu -> MarsApiFilter.SHOW_RENT
        R.id.show_buy_menu -> MarsApiFilter.SHOW_BUY
        else -> MarsApiFilter.SHOW_ALL
      }
    )
    return true
  }
}

Create a detail page with navigation

When the user taps the tile, pass the data to the detail page.

// DetailViewModel.kt
class DetailViewModel(marsProperty: MarsProperty,
    app: Application) : AndroidViewModel(app) {
  private val _selectedProperty = MutableLiveData<MarsProperty>()
  val selectedProperty: LiveData<MarsProperty>
    get() = _selectedProperty

  init {
    _selectedProperty.value = marsProperty
  }
}

Update the fragment_detail.xml.

<ImageView
  android:id="@+id/main_photo_image"
  ...
  app:imageUrl="@{viewModel.selectedProperty.imgSrcUrl}" />

<!-- ... -->
<data>
  <variable
    name="viewModel"
    type="com.example.android.marsrealestate.detail.DetailViewModel" />
</data>

Then, add navigation with passing property.

// OverviewViewModel.kt
class OverviewViewModel : ViewModel() {
  // ...
  private val _navigateToSelectedProperty = MutableLiveData<MarsProperty>()
  val navigateToSelectedProperty: LiveData<MarsProperty>
    get() = _navigateToSelectedProperty

  fun displayPropertyDetails(marsProperty: MarsProperty) {
    _navigateToSelectedProperty.value = marsProperty
  }

  fun displayPropertyDetailsComplete() {
    _navigateToSelectedProperty.value = null
  }
}

Add click listeners in the grid adapter and fragment.

// PhotoGridAdapter.kt
class PhotoGridAdapter(private val onClickListener: OnClickListener) : ListAdapter<MarsProperty, PhotoGridAdapter.MarsPropertyViewHolder>(DiffCallback) {
  // ...
  override fun onBindViewHolder(holder: MarsPropertyViewHolder, position: Int) {
    val marsProperty = getItem(position)
    holder.itemView.setOnClickListener {
      onClickListener.onClick(marsProperty)
    }
    holder.bind(marsProperty)
  }
  // ...
  class OnClickListener(val clickListener: (marsProperty: MarsProperty) -> Unit) {
    fun onClick(marsProperty: MarsProperty) = clickListener(marsProperty)
  }
}

Then, update the OverviewFragment.kt.

binding.photosGrid.adapter = PhotoGridAdapter(PhotoGridAdapter.OnClickListener {
    viewModel.displayPropertyDetails(it)
})

MarsProperty is unable to pass through the navigation yet. The class need to be Parcelable via @parcelize.

Open MarsProperty.kt and add @parcelize.

@Parcelize
data class MarsProperty(
  val id: String,
  @Json(name = "img_src") val imgSrcUrl: String,
  val type: String,
  val price: Double
) : Parcelable {
  val isRental
    get() = type == "rent"
}

Add argument at detail fragment in nav_graph.xml.

<fragment
  android:id="@+id/detailFragment"
  ...>
  <argument
    android:name="selectedProperty"
    app:argType="com.example.android.marsrealestate.network.MarsProperty"
    />
</fragment>

Finally, add an observer to navigateToSelectedProperty.

//OverviewFragment.kt
// in `onCreateView()`
viewModel.navigateToSelectedProperty.observe(this, Observer {
  if (null != it) {
    this.findNavController()
      .navigate(OverviewFragmentDirections.actionShowDetail(it))
    viewModel.displayPropertyDetailsComplete()
  }
})

Add initial logic for the detail fragment.

// DetailFragment.kt
class DetailFragment : Fragment() {
  override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
      savedInstanceState: Bundle?): View? {
    val application = requireNotNull(activity).application
    val binding = FragmentDetailBinding.inflate(inflater)
    binding.lifecycleOwner = this

    val marsProperty = DetailFragmentArgs.fromBundle(arguments!!).selectedProperty
    val viewModelFactory = DetailViewModelFactory(marsProperty, application)
    binding.viewModel = ViewModelProvider(this, viewModelFactory).get(DetailViewModel::class.java)

    return binding.root
  }
}

To update the detail page, add these string resources in strings.xml.

<string name="type_rent">Rent</string>
<string name="type_sale">Sale</string>
<string name="display_type">For %s</string>
<string name="display_price_monthly_rental">$%,.0f/month</string>
<string name="display_price">$%,.0f</string>

Then, add Transformations in the view model.

// DetailViewModel.kt
// In the detail viewmodel class
val displayPropertyPrice = Transformations.map(selectedProperty) {
  app.applicationContext.getString(
    when (it.isRental) {
      true -> R.string.display_price_monthly_rental
      false -> R.string.display_price
    }, it.price)
}

val displayPropertyType = Transformations.map(selectedProperty) {
  app.applicationContext.getString(
    when (it.isRental) {
      true -> R.string.type_rent
      false -> R.string.type_sale
    })
}

Add two textviews on the detail layout.

<TextView
  android:id="@+id/property_type_text"
  ...
  android:text="@{viewModel.displayPropertyType}"
  tools:text="To Rent" />

<TextView
  android:id="@+id/price_value_text"
  ...
  android:text="@{viewModel.displayPropertyPrice}"
  tools:text="$100,000" />

다음 챕터: Android Kotlin Fundamentals

Android Kotlin Fundamentals Course 코드랩 하면서 노트. Android Kotlin Fundamentals: 8.1 Getting data from the internet 부터.

dep versions

Just in case.

buildscript {
  ext {
    // Versions for all the dependencies we plan to use. It's particularly useful for kotlin and
    // navigation where the versions of the plugin needs to be the same as the version of the
    // library defined in the app Gradle file

    version_android_gradle_plugin = '4.0.1'
    version_core = "1.3.1"
    version_constraint_layout = "1.1.3"
    version_glide = "4.8.0"
    version_kotlin = "1.3.72"
    version_lifecycle = "2.2.0"
    version_moshi = "1.8.0"
    version_navigation = "1.0.0"
    version_retrofit = "2.9.0"
    version_recyclerview = "1.0.0"
  }
}

Retrofit: A type-safe HTTP client

// build.gradle (project)
buildscript {
  ext {
    //...
    version_retrofit = "2.9.0"
  }
}
// build.gradle (module: app)
dependencies {
  // ...
  implementation "com.squareup.retrofit2:retrofit:$version_retrofit"
  implementation "com.squareup.retrofit2:converter-scalars:$version_retrofit"
}

android {
  // ...

  compileOptions {
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
  }

  kotlinOptions {
    jvmTarget = JavaVersion.VERSION_1_8.toString()
  }
}

Then, create MarsApiService.kt.

package com.example.android.marsrealestate.network

import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.converter.scalars.ScalarsConverterFactory
import retrofit2.http.GET

// private const val BASE_URL = "https://android-kotlin-fun-mars-server.appspot.com/"

private val retrofit = Retrofit.Builder()
  .addConverterFactory(ScalarsConverterFactory.create())
  .baseUrl(BASE_URL)
  .build()

interface MarsApiService {
  @GET("realestate")
  fun getProperties():
      Call<String>
}

object MarsApi {
  val retrofitService: MarsApiService by lazy {
    retrofit.create(MarsApiService::class.java)
  }
}

Call the web service in OverviewViewModel.kt.

class OverviewViewModel : ViewModel() {
  // ...
  private fun getMarsRealEstateProperties() {
    MarsApi.retrofitService.getProperties().enqueue(
      object : Callback<String> {
        override fun onFailure(call: Call<String>, t: Throwable) {
          _response.value = "Failure: " + t.message
        }

        override fun onResponse(call: Call<String>, response: Response<String>) {
          _response.value = response.body()
        }
      }
    )
  }
}

Add INTERNET permission in app/manifests/AndroidManifest.xml.

<manifest ...>
  <uses-permission android:name="android.permission.INTERNET" />
</manifest>

Moshi: JSON parser library

// build.gradle (project)
buildscript {
  ext {
    //...
    version_moshi = "1.8.0"
  }
}
// build.gradle (module: app)
dependencies {
  // ...
  implementation "com.squareup.moshi:moshi-kotlin:$version_moshi"
  // change retrofit2's scalars converter
  implementation "com.squareup.retrofit2:converter-moshi:$version_retrofit"
}

Then, sync.

Create the property data class.

// MarsProperty.kt
data class MarsProperty(
  val id: String,
  // val img_src: String,
  // to remap the name
  @Json(name = "img_src") val imgSrcUrl: String,
  val type: String,
  val price: Double
)

Update MarsApiService.kt with new converter.

/// MarsApiService.kt

private val moshi = Moshi.Builder()
  .add(KotlinJsonAdapterFactory())
  .build()

private val retrofit = Retrofit.Builder()
  .addConverterFactory(MoshiConverterFactory.create(moshi))
  .baseUrl(BASE_URL)
  .build()

interface MarsApiService {
  @GET("realestate")
  fun getProperties():
    Call<List<MarsProperty>>
}

Update the ViewModel.

class OverviewViewModel : ViewModel() {
  // ...
  private fun getMarsRealEstateProperties() {
    MarsApi.retrofitService.getProperties().enqueue(
      object : Callback<List<MarsProperty>> {
        override fun onFailure(call: Call<List<MarsProperty>>, t: Throwable) {
          _response.value = "Failure: " + t.message
        }

        override fun onResponse(call: Call<List<MarsProperty>>, response: Response<List<MarsProperty>>) {
          _response.value =
            "Success: ${response.body()?.size} Mars properties retrieved"
        }
      }
    )
  }
}

It is a good idea to use coroutines for asynchronous.

// MarsApiService.kt
// Change the function with suspend
interface MarsApiService {
  @GET("realestate")
  suspend fun getProperties():
    List<MarsProperty>
}

Then, replace the ViewModel like the code below using try {}, catch {}, and viewModelScope.

// OverviewViewModel.kt
private fun getMarsRealEstateProperties() {
  viewModelScope.launch {
    try {
      val listResult = MarsApi.retrofitService.getProperties()
      _response.value = "Success: ${listResult.size} Mars properties retrieved"
    } catch (e: Exception) {
      _response.value = "Failure: ${e.message}"
    }
  }
}

Glide: Media mgmt and loading framework

// build.gradle (project)
buildscript {
  ext {
    //...
    version_glide = "4.8.0"
  }
}
// build.gradle (module: app)
dependencies {
  // ...
  implementation "com.github.bumptech.glide:glide:$version_glide"
}

Update the view model.

// OverviewViewModel.kt
private val _property = MutableLiveData<MarsProperty>()

val property: LiveData<MarsProperty>
  get() = _property

private fun getMarsRealEstateProperties() {
  viewModelScope.launch {
    try {
      val listResult = MarsApi.retrofitService.getProperties()
      if (listResult.size > 0) {
          _property.value = listResult[0]
      }
    } catch (e: Exception) {
      _response.value = "Failure: ${e.message}"
    }
  }
}

Add BindingAdapters.kt.

// BindingAdapters.kt
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
  imgUrl?.let {
    val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()
    Glide.with(imgView.context)
      .load(imgUri)
      .into(imgView)
  }
}

Update gridview_item.xml.

<layout ...>
  <ImageView
      android:id="@+id/mars_image"
      ...
      app:imageUrl="@{viewModel.property.imgSrcUrl}" />

  <data>
    <variable
      name="viewModel"
      type="com.example.android.marsrealestate.overview.OverviewViewModel" />
  </data>
</layout>

Change the layout from FragmentOverviewBinding to GridViewItemBinding in the fragment to quick test.

// val binding = FragmentOverviewBinding.inflate(inflater)
val binding = GridViewItemBinding.inflate(inflater)

Add loading and broken image

Add files in res/drawable. Here, ic_broken_image.xml and loading_animation.xml.

Update BindingAdapters.kt and reflect this file in bindImage().

// BindingAdapter.kt
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
  imgUrl?.let {
    val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()
    Glide.with(imgView.context)
      .load(imgUri)
      .apply(RequestOptions()
        .placeholder(R.drawable.loading_animation)
        .error(R.drawable.ic_broken_image))
      .into(imgView)
  }
}

Update RecyclerView with loaded data

// OverviewViewModel.kt
private val _properties = MutableLiveData<List<MarsProperty>>()
val properties: LiveData<List<MarsProperty>>
  get() = _properties

// ...
private fun getMarsRealEstateProperties() {
  viewModelScope.launch {
    try {
      _properties.value = MarsApi.retrofitService.getProperties()
      _response.value = "Success: Mars properties retrieved"
    } catch (e: Exception) {
      _response.value = "Failure: ${e.message}"
    }
  }
}

Update gridview_item.xml for the each property.

<layout ...>
  <ImageView
    android:id="@+id/mars_image"
    ...
    app:imageUrl="@{property.imgSrcUrl}" />

  <data>
    <variable
      name="property"
      type="com.example.android.marsrealestate.network.MarsProperty" />
  </data>
</layout>

Change the layout back to FragmentOverviewBinding in the fragment.

val binding = FragmentOverviewBinding.inflate(inflater)
// val binding = GridViewItemBinding.inflate(inflater)

Replace <TextView> to <RecyclerView> in fragment_overview.xml.

<androidx.recyclerview.widget.RecyclerView
  android:id="@+id/photos_grid"
  android:layout_width="0dp"
  android:layout_height="0dp"
  android:padding="6dp"
  android:clipToPadding="false"
  app:layoutManager=
      "androidx.recyclerview.widget.GridLayoutManager"
  app:layout_constraintBottom_toBottomOf="parent"
  app:layout_constraintLeft_toLeftOf="parent"
  app:layout_constraintRight_toRightOf="parent"
  app:layout_constraintTop_toTopOf="parent"
  app:spanCount="2"
  tools:itemCount="16"
  tools:listitem="@layout/grid_view_item" />

Then, Add the photo grid adapter.

// PhotoGridAdapter.kt
class PhotoGridAdapter : ListAdapter<MarsProperty, PhotoGridAdapter.MarsPropertyViewHolder>(DiffCallback) {

  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MarsPropertyViewHolder {
    return MarsPropertyViewHolder(GridViewItemBinding.inflate(LayoutInflater.from(parent.context)))
  }

  override fun onBindViewHolder(holder: MarsPropertyViewHolder, position: Int) {
    val marsProperty = getItem(position)
    holder.bind(marsProperty)
  }

  companion object DiffCallback : DiffUtil.ItemCallback<MarsProperty>() {
    override fun areItemsTheSame(oldItem: MarsProperty, newItem: MarsProperty): Boolean {
      return oldItem == newItem
    }

    override fun areContentsTheSame(oldItem: MarsProperty, newItem: MarsProperty): Boolean {
      return oldItem.id == newItem.id
    }
  }

  class MarsPropertyViewHolder(private var binding: GridViewItemBinding) : RecyclerView.ViewHolder(binding.root) {
    fun bind(marsProperty: MarsProperty) {
      binding.property = marsProperty
      binding.executePendingBindings()
    }
  }
}

Update the Binding adapter.

// BindingAdapters.kt
@BindingAdapter("listData")
fun bindRecyclerView(recyclerView: RecyclerView, data: List<MarsProperty>?) {
  val adapter = recyclerView.adapter as PhotoGridAdapter
  adapter.submitList(data)
}

Update the fragment.

<!-- fragment_overview.xml -->
<RecyclerView ...
  app:listData="@{viewModel.properties}" />

Add the adapter into the fragment.

binding.photosGrid.adapter = PhotoGridAdapter()
// Note: photos_grid is a name of the recycler view

Add error handling in RecyclerView

Add status in the view model.

enum class MarsApiStatus { LOADING, ERROR, DONE }

class OverviewViewModel : ViewModel() {

  // ...

  private val _status = MutableLiveData<MarsApiStatus>()
  val status: LiveData<MarsApiStatus>
    get() = _status

  private fun getMarsRealEstateProperties() {
    viewModelScope.launch {
      _status.value = MarsApiStatus.LOADING
      try {
        _properties.value = MarsApi.retrofitService.getProperties()
        _status.value = MarsApiStatus.DONE

      } catch (e: Exception) {
        _status.value = MarsApiStatus.ERROR
        _properties.value = ArrayList()
      }
    }
  }
}

Update BindingAdapters.kt.

// BindingAdapters.kt

@BindingAdapter("marsApiStatus")
fun bindStatus(statusImageView: ImageView, status: MarsApiStatus?) {
  when (status) {
    MarsApiStatus.LOADING -> {
      statusImageView.visibility = View.VISIBLE
      statusImageView.setImageResource(R.drawable.loading_animation)
    }
    MarsApiStatus.ERROR -> {
      statusImageView.visibility = View.VISIBLE
      statusImageView.setImageResource(R.drawable.ic_connection_error)
    }
    MarsApiStatus.DONE -> {
      statusImageView.visibility = View.GONE
    }
  }
}

Then, add the image view for the status.

<!-- fragment_overview.xml -->
<ImageView
   android:id="@+id/status_image"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:marsApiStatus="@{viewModel.status}" />

다음 챕터: Android Kotlin Fundamentals and detail views with internet data

색상을 바꿔요

눈에 편한 색상을 골라보세요 :)

Darkreader 플러그인으로 선택한 색상이 제대로 표시되지 않을 수 있습니다.