1. 프로그래머스 앱 데브 매칭 2021 : K-MOOC 강좌정보 서비스 앱 과제
프로그래머스에서 금년도 6월 19일에 진행한 앱 데브 매칭에 대한 의도한 해답을 공개했다. 해당 과제를 내가 치룰 당시에는 안드로이드 뷰 모델에 대한 학습이 안되어있어서 당황하며 어찌저찌 거의 구현은 했는데 나 자신이 만족할만한 수준의 앱이 아니어서 상당히 아쉬웠다.
데브 매칭의 경우에는 다른 블로그 글들을 찾아보면 메일로 코드 리뷰 및 피드백을 주는 것을 본 적이 있는데, 이번에는 공개해답을 아예 공식 블로그에 공개를 해주었다. 안드로이드 신입 개발자를 준비하고 있는 나에게는 참 반가운 소식이었다.
물론 코드에는 정답이 없지만 어느 정도 통용되는 또는 안드로이드에서 적극 권장하는 아키텍처가 분명 존재하기 때문에 이러한 예시 앱이나 해설에 대한 갈증이 많았었다. 공식 문서는 깔끔하지만 친절하지 않은데 이번 출제자님의 해설은 그나마 친절한 편이라고 생각한다. 😂
앱 최종 결과 스크린샷
앱의 기능은 이렇게만 보면 엄청 간단한 편이다. 공공 데이터 포털에서 제공하는 K-MOOK 강좌 공개 api를 사용해서 강좌 리스트를 리사이클러뷰에 뿌리고 클릭 시 해당 강좌에 디테일한 정보와 html 을 웹뷰에 뿌려주는 형식으로 앱 구현 요구사항이 주어진다.
앱 아키택처
앱 아키텍쳐의 경우에는 안드로이드에서 권장하는 아키텍처를 따르며 유저 인터페이스는 목록(KmookListActivity) 과 상세 (KmookDetailActivity) 두 가지 액티비티로 이루어져 있는 구조입니다. (목록에서 강좌 클릭 시 상세로 넘어가는 방식)
이러한 아키텍처를 모두 구현하지 않아도 기본적으로 베이스 코드가 주어졌습니다. ViewModel, Repository 등 기본적인 내용은 미리 구성되어 코드로 주어졌고 나머지 부분을 채워 과제를 완성해가는 내용이었지요. (아래)
api 서버를 사용하기 위해 http 통신을 위해 보통 retrofit 과 같은 외부 라이브러리에 의존하지만 이번 과제에서는 외부 라이브러리 사용이 불가했고 network 패키지에 이를 위한 HttpClient 등이 베이스 코드로 주어져서 이를 활용만 하면 됐습니다.
LiveData 나 DI 에 대해서는 구현자의 과제로 남겨두었고 필수는 아니었습니다. DI에 대한 구현 코드 해답은 따로 없기 때문에 이 부분에 대해서도 나중에 구현하여 이 글에 업데이트 하도록 하겠습니다. (규모가 작은 앱이라 DI가 필수는 아니지만 활용할 수 있는 모습을 보여주면 좋겠어서요.)
요구 사항
KmoocListActivity
- fetching된 데이터를 adapter를 통해 표시하는 부분
- progressBar 표시
- pull to refresh
- 무한로딩
KmoocDetailActivity
- 상세 데이터를 조회하여 기본정보 적용 및 *webview를 사용한 표시 기능
ImageLoader
- *이미지를 로딩처리(async 한 처리 구현과 cache(이미지 캐싱))
KmoocRepository
- json 을 파싱하여 Model 객체 생성
* 과제 당시 구현하지 못했던 기능
2. 앱 구조를 전반적으로 살펴보기
프로젝트 공개 해설 전체 코드는 프로그래머스 공식 블로그에서 확인 및 다운로드 할 수 있습니다.
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.programmers.kmooc">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".KmoocApplication"
...>
<activity android:name=".activities.detail.KmoocDetailActivity"></activity>
<activity android:name=".activities.list.KmoocListActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
앱 매니페스트를 보면 애플리케이션은 KmoocApplication 이며, KmoocListActivity 가 런치시 기본 액티비티로 실행되는 것을 알 수 있고 KmoocDetailActivity 도 앱에서 사용되는 것을 알 수 있습니다.
KmoocApplication.kt
class KmoocApplication : Application() {
val kmoocRepository = KmoocRepository()
}
KmoocApplication 은 KmoocRepository 를 가지고 있습니다. KmoocRepository 는 HttpClient 를 사용하여 서버에서 json 데이터를 받아와 처리하는 클래스입니다.
KmoocRepository.kt
class KmoocRepository {
private val httpClient = HttpClient("http://apis.data.go.kr/B552881/kmooc")
private val serviceKey = ...
fun list(completed: (LectureList) -> Unit) {
httpClient.getJson(
"/courseList",
mapOf("serviceKey" to serviceKey, "Mobile" to 1)
) { result ->
result.onSuccess {
completed(parseLectureList(JSONObject(it)))
}
}
}
fun next(currentPage: LectureList, completed: (LectureList) -> Unit) {
val nextPageUrl = currentPage.next
httpClient.getJson(nextPageUrl, emptyMap()) { result ->
result.onSuccess {
completed(parseLectureList(JSONObject(it)))
}
}
}
fun detail(courseId: String, completed: (Lecture) -> Unit) {
httpClient.getJson(
"/courseDetail",
mapOf("CourseId" to courseId, "serviceKey" to serviceKey)
) { result ->
result.onSuccess {
completed(parseLecture(JSONObject(it)))
}
}
}
private fun parseLectureList(jsonObject: JSONObject): LectureList {
//TODO: JSONObject -> LectureList 를 구현하세요
return LectureList.EMPTY
}
private fun parseLecture(jsonObject: JSONObject): Lecture {
//TODO: JSONObject -> Lecture 를 구현하세요
return Lecture.EMPTY
}
}
http 클라이언트에서는 json 데이터 요청시 내부에서 코루틴을 통해서 비동기로 데이터를 요청하여 완료시 지정한 콜백으로 데이터를 처리할 수 있게 해줍니다. 구현해야하는 부분은 TODO 주석이 매겨져 있습니다.
KmoocListViewModel.kt
class KmoocListViewModel(private val repository: KmoocRepository) : ViewModel() {
fun list() {
repository.list { lectureList ->
}
}
fun next() {
val currentLectureList = LectureList.EMPTY
repository.next(currentLectureList) { lectureList ->
}
}
}
class KmoocListViewModelFactory(private val repository: KmoocRepository) :
ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(KmoocListViewModel::class.java)) {
return KmoocListViewModel(repository) as T
}
throw IllegalAccessException("Unkown Viewmodel Class")
}
}
뷰 모델은 레포지토리를 기본적으로 넘겨받아 생성하게 되며 내부에서 list와 next 로 강좌 리스트와 다음 강좌 리스트 처리 구현을 해주게됩니다. KmoocListViewModel 은 ViewModelProvider.Factory 인터페이스를 구현한 팩토리 클래스의 생성 메서드로 인스턴스를 생성합니다. (이 부분에 대해 낯설게 느껴지신 다면 ViewModel 에 대한 기본 학습이 필요합니다.)
KmoocListActivity.kt
class KmoocListActivity : AppCompatActivity() {
private lateinit var binding: ActivityKmookListBinding
private lateinit var viewModel: KmoocListViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val kmoocRepository = (application as KmoocApplication).kmoocRepository
viewModel = ViewModelProvider(this, KmoocListViewModelFactory(kmoocRepository)).get(
KmoocListViewModel::class.java
)
binding = ActivityKmookListBinding.inflate(layoutInflater)
setContentView(binding.root)
...
viewModel.list()
}
...
}
메인 액티비티인 KmoocListActivity 에서는 위에서 정의한 뷰모델 인스턴스를 생성하여 사용하게 되며 onCreate 시 가장 마지막으로 viewModel 의 list()를 호출해서 강좌 리스트를 갱신하게 됩니다.
3. 서버로 부터 받아온 Json 파싱하기
Json 을 파싱할 때는 보통 2가지 방식을 사용하는 데 첫 번째로는 Entitiy 객체를 만들어 사용하는 방식이 있고 두 번째로는 원하는 값을 직접 파싱하는 방법이 있습니다. 해설 코드에서는 후자를 사용합니다. 이때 안드로이드에서 json 을 다룰 수 있는 JSONObject 를 사용합니다.
먼저 강좌 리스트의 json 데이터 샘플을 확인해보도록 합시다.
{
"pagination": {
"count": 1159,
"previous": null,
"num_pages": 116,
"next": "http:\/\/www.kmooc.kr\/api\/courses\/v1\/course\/list\/?Mobile=1&SG_APIM=2ug8Dm9qNBfD32JLZGPN64f3EoTlkpD8kSOHWfXpyrY&page=2&serviceKey=9%2FCIHgZI1SKc5ppDSwmx0REDZtF61KNeVHqxA54N6MpyAwrf9v%2BOzBvfOxoQyh8%2F8a26oASfPpEFCmnuncdGGA%3D%3D"
},
"results": [
{
"blocks_url": "http:\/\/www.kmooc.kr\/api\/courses\/v1\/blocks\/?course_id=course-v1%3AACRCEDU%2BACRC01%2B2020_02",
"effort": "00:15@07#01:40$07:00",
"end": "2020-10-31T14:30:00Z",
"enrollment_start": "2020-09-07T00:00:00Z",
"enrollment_end": "2020-10-31T14:30:00Z",
"id": "course-v1:ACRCEDU+ACRC01+2020_02",
"media": {
"course_image": {
"uri": "\/asset-v1:ACRCEDU+ACRC01+2020_02+type@asset+block@1022_청렴_저용량__썸네일.png"
},
"course_video": {
"uri": null
},
"image": {
"raw": "http:\/\/www.kmooc.kr\/asset-v1:ACRCEDU+ACRC01+2020_02+type@asset+block@1022_%EC%B2%AD%EB%A0%B4_%EC%A0%80%EC%9A%A9%EB%9F%89__%EC%8D%B8%EB%84%A4%EC%9D%BC.png",
"small": "http:\/\/www.kmooc.kr\/asset-v1:ACRCEDU+ACRC01+2020_02+type@asset+block@1022_%EC%B2%AD%EB%A0%B4_%EC%A0%80%EC%9A%A9%EB%9F%89__%EC%8D%B8%EB%84%A4%EC%9D%BC.png",
"large": "http:\/\/www.kmooc.kr\/asset-v1:ACRCEDU+ACRC01+2020_02+type@asset+block@1022_%EC%B2%AD%EB%A0%B4_%EC%A0%80%EC%9A%A9%EB%9F%89__%EC%8D%B8%EB%84%A4%EC%9D%BC.png"
}
},
"name": "문화와 생활 속 청렴",
"number": "ACRC01",
"org": "ACRCEDU",
"short_description": "문화와 생활 속 청렴강좌는 인문학 속 역사 이야기와 생활 속 청렴 정보통으로 구성되어 있습니다\n인문학 속 역사 이야기에서는 역사 속 인물, 영화 속 주인공, 대중문화의 사례를 통해 쉽고 재미있게 청렴에 대해 다시 한 번 생각해볼 수 있는 시간을 드립니다\n또한 생활 속 청렴 정보통에서는 어렵게만 느껴졌던 청탁금지법, 부패 및 공익신고, 우리나라 청렴지수 등 국민들이 알아야 할 생활 속 반부패 법령, 제도의 정보를 쉽고 재미있게 풀어드리겠습니다\n",
"start": "2020-09-07T00:00:00Z",
"start_display": "Sept. 7, 2020",
"start_type": "timestamp",
"pacing": "instructor",
"mobile_available": true,
"hidden": false,
"invitation_only": false,
"teachers": "신병주, 윤성은, 하재근, 오수진, 이정수",
"classfy": "hum",
"middle_classfy": "husc",
"classfy_plus": "all",
"course_period": "M",
"level": "1",
"passing_grade": "0.70",
"audit_yn": "Y",
"fourth_industry_yn": "N",
"home_course_yn": "N",
"home_course_step": "",
"ribbon_yn": "N",
"job_edu_yn": "N",
"linguistics": "N",
"created": "2020-09-03T11:25:18Z",
"modified": "2021-06-09T06:51:09Z",
"ai_sec_yn": "N",
"basic_science_sec_yn": "N",
"org_name": "국민권익위원회 청렴연수원",
"classfy_name": "인문",
"middle_classfy_name": "인문과학",
"language_name": "한국어",
"effort_time": "07:00",
"video_time": "01:40",
"week": "07",
"learning_time": "00:15",
"preview_video": "",
"course_id": "course-v1:ACRCEDU+ACRC01+2020_02"
}, ...]
데이터를 확인해보면 json 상위에 pagination (페이지 매김 데이터)와 result (각 강좌의 리스트 데이터) 데이터가 있는 것을 알 수 있습니다. 이때 LectureList 를 생성하는 데 필요한 정보만 취사선택하여 파싱하면 됩니다.
parseLectureList
페이지 정보와 lectures: List<Lecture> 를 가지는 LectureList 를 파싱을 통해 생성해 반환합니다.
이는 KmoocRepository 클래스의 list, next 의 complete 콜백의 인자를 넘길 때 parseLectureList 를 통해 파싱 후 LectureList 를 넘기게 되어 complete 콜백에서 알맞은 처리가 이루어지게 됩니다.
즉, 앱 최초 실행 시 메인 액티비티(리스트 액티비티)에서 viewModel.list() 를 호출하게되고 이때 lectureList 를 갱신할 수 있습니다.
3.1. 리사이클러 뷰에 강좌 리스트 갱신하기
KmoocListViewModel : list()
class KmoocListViewModel(private val repository: KmoocRepository) : ViewModel() {
var progressVisible = MutableLiveData<Boolean>()
var lectureList = MutableLiveData<LectureList>()
fun list() {
progressVisible.postValue(true)
repository.list {
this.lectureList.postValue(it)
progressVisible.postValue(false)
}
}
...
}
Kmooc 리스트 뷰모델에서 list() 메서드는 콜백으로 위와 같이 LectureList(it) 를 받아 LiveData 에 post 해주는 것을 알 수 있습니다. 그러면 이후 해당 라이브 데이터의 변경을 리스트 액티비티에서 정의한 옵저버가 감지하여 리사이클러 어뎁터에 해당 강좌 리스트를 갱신합니다.
class KmoocListActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
binding.lectureList.adapter = adapter
viewModel.lectureList.observe(this) { lectureList ->
adapter.updateLectures(lectureList.lectures)
binding.pullToRefresh.isRefreshing = false
}
...
4. 무한 스크롤 기능 구현하기
무한 스크롤 기능은 리사이클러 뷰에 스크롤 리스너를 추가해서 구현합니다.
class KmoocListActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
binding.lectureList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val layoutManager = binding.lectureList.layoutManager
// hasNextPage() -> 다음 페이지가 있는 경우
if (viewModel.progressVisible.value != true) {
val lastVisibleItem = (layoutManager as LinearLayoutManager)
.findLastCompletelyVisibleItemPosition()
// 마지막으로 보여진 아이템 position 이
// 전체 아이템 개수보다 5개 모자란 경우, 데이터를 loadMore 한다
if (layoutManager.itemCount <= lastVisibleItem + 5) {
viewModel.next()
}
}
}
})
...
}
리사이클러 뷰의 레이아웃 매니저를 통해서 가장 마지막으로 보여지고 있는 아이템 위치를 가져와서 해당 위치가 전체 아이템 개수보다 5개 전에 위치한 경우에 데이터를 추가로 로드해서 무한 스크롤 기능을 구현합니다.
이로써 유저가 만약 스크롤을 거의 마지막(5개 아이템 전)까지 하게 되면 자동으로 다음 페이지를 로드해서 볼 수 있도록 해줍니다. 한꺼번에 전체 페이지를 로드하는 것이 아닌 필요할 때 필요한 만큼의 페이지를 로드하게 되는 것이지요.
class KmoocListViewModel(private val repository: KmoocRepository) : ViewModel() {
var progressVisible = MutableLiveData<Boolean>()
var lectureList = MutableLiveData<LectureList>()
...
fun next() {
progressVisible.postValue(true)
val currentLectureList = this.lectureList.value ?: return
repository.next(currentLectureList) { lectureList ->
val currentLectures = currentLectureList.lectures
val mergedLectures = currentLectures.toMutableList()
.apply { addAll(lectureList.lectures) }
lectureList.lectures = mergedLectures // 새로 받아온 페이징 정보 + 머지 데이터 대입
this.lectureList.postValue(lectureList)
progressVisible.postValue(false)
}
}
}
리스트 뷰 모델에서는 next() 에서 이러한 처리를 하게됩니다. 이때 기존 페이지에 있던 강좌 리스트 뒤에 새로 받아온 페이지의 강좌 리스트를 합칩니다. 여기서 중요한 점은 LectureList 객체 자체는 기존 것이 아닌 새로 얻어온 페이지의 페이징 정보를 가진 객체라는 점 입니다.
이를 통해 다음번 next() 호출 시에도 그 다음 페이지를 이어서 받아올 수 있게됩니다.
또한 깨알같이 프로그레스바의 보여짐 여부 또한 LiveData로 관리해서 적절하게 보여주거나 가리도록 하고 있습니다.
4.2. Pull to refreshing (아래로 당겨서 새로고침) 구현
class KmoocListActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
binding.pullToRefresh.setOnRefreshListener {
viewModel.list()
}
...
binding.pullToRefresh 는 SwipeRefreshLayout 으로 해당 컴포넌트는 setOnRefreshListener 로 pull to refresh 를 쉽게 구현할 수 있게 해줍니다. 아래로 당겨 새로고침하는 액션에 뷰모델이 가지고 있는 list()를 호출해주면 구현 완료입니다.
여기서는 아래로 당겨 새로고침의 경우 일반적으로 유저가 생각할 수 있는 새로고침 즉, 초기 리스트를 다시 새로고침 (로딩 되었던 다른 페이지는 새로고침 대상이 아님) 하는 방식의 새로고침을 말합니다. (유튜브 같은 경우에는 새로고침시 새로운 영상이 상위에 업데이트된다.)
5. 비동기 이미지 로드
이미지 로드 작업은 오래걸리는 네트워크 작업에 속하므로 IO 스레드에서 비동기로 처리하여 UI가 끊김없이 유저에게 경험될 수 있도록 해주는 것이 바람직합니다. 보통 외부 라이브러리인 Glide 가 이를 대신 해주었는데 코루틴을 사용해서 직접 구현해야합니다.
이미지 로드 까지는 어찌저찌 하였지만 이미지 캐싱에 대해서는 구현하지 못했어서 어떻게 구현 하라는 걸까 궁금했는데 해설 코드를 보니 약간 허무했는데.. 이미지 캐싱의 경우에는 맵을 사용해서 이전에 해당 url 의 비트맵을 로드한 적이 있다면 캐싱된 비트맵을 사용하는 방법을 사용하는것이었습니다.
object ImageLoader {
private val imageCache = mutableMapOf<String, Bitmap>()
fun loadImage(url: String, completed: (Bitmap?) -> Unit) {
if (url.isEmpty()) {
completed(null)
return
}
if (imageCache.containsKey(url)) {
completed(imageCache[url])
return
}
GlobalScope.launch(Dispatchers.IO) {
try {
val bitmap = BitmapFactory.decodeStream(URL(url).openStream())
imageCache[url] = bitmap
withContext(Dispatchers.Main) {
completed(bitmap)
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
completed(null)
}
}
}
}
먼저 url 이 비어있으면 이미지가 없는 것으로 이미지 처리를 하지 않고 반환처리합니다.
다음으로는 이전에 이미 해당 url을 처리한 적이 있는 경우 맵에서 해당 url을 키로 전달하여 bitmap을 받아올 수 있습니다. 이를 콜백함수로 지정한 completed 에 전달해서 이미지를 로드합니다.
다음으로 최초 이미지 로드시 코루틴을 사용해서 이미지를 로드합니다. BitmapFactory.decodeStream(URL(url).openStream()) 코드를 통해서 이미지를 URL로부터 Bitmap 을 생성할 수 있고 이는 IO 스레드에서 처리됩니다.
Bitmap을 성공적으로 받아온 경우 Main 스레드에서 UI 작업(completed 콜백)을 수행합니다.
LectureViewHolder
class LectureViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val binding = ViewKmookListItemBinding.bind(itemView)
private val thumbnail = binding.lectureImage
...
fun bind(lecture: Lecture) {
...
ImageLoader.loadImage(lecture.courseImage) { bitmap ->
thumbnail.setImageBitmap(bitmap)
}
}
}
리사이클러뷰 어뎁터의 뷰 홀더 클래스로 사용되는 LectureViewHolder 의 bind 메서드에서 강좌의 url 주소를 ImageLoader.loadImage 에 넘기면서 람다로 completed 시 처리할 작업(Bitmap 이미지 설정)을 지정합니다.
6. 상세 정보를 웹뷰에 표시하기
detail activity 의 강좌 상세 정보의 하단 부분은 web view를 통해서 강좌의 자세한 정보를 표시하게됩니다. api 를 통해서 detail 정보를 받아올 때 html 형식 string 을 받아오는데 이를 활용해서 웹뷰에 보여주면 되는데 json 형식은 아래와 같이 주어지게됩니다.
Detail json sample
{
"blocks_url": "http:\/\/www.kmooc.kr\/api\/courses\/v1\/blocks\/?course_id=course-v1%3AAIIA%2BAIIA01%2B2021_T2_AIIA01",
"effort": "00:40@15#10:00$10:00",
"end": "2021-07-31T23:30:00Z",
...,
"media": {
"course_image": {
"uri": "\/asset-v1:AIIA+AIIA01+2021_T2_AIIA01+type@asset+block@2021-04-07_14_20_44f.PNG"
},
"course_video": {
"uri": "http:\/\/www.youtube.com\/watch?v=JWggZMvQNus"
},
"image": {
"raw": "http:\/\/www.kmooc.kr\/asset-v1:AIIA+AIIA01+2021_T2_AIIA01+type@asset+block@2021-04-07_14_20_44f.PNG",
"small": "http:\/\/www.kmooc.kr\/asset-v1:AIIA+AIIA01+2021_T2_AIIA01+type@asset+block@2021-04-07_14_20_44f.PNG",
"large": "http:\/\/www.kmooc.kr\/asset-v1:AIIA+AIIA01+2021_T2_AIIA01+type@asset+block@2021-04-07_14_20_44f.PNG"
}
},
"name": "확률적 그래픽 모델",
...,
"overview": "<div id=\"course-info\">\n<section class=\"about\">\n<h2><i class=\"fa fa-university\"><\/i>강좌 소개<\/h2>\n<article>\n<h3><i class=\"fa fa-pencil-square-o\"><\/i>수업내용\/목표<\/h3>\n<div class=\"article_contents goal\">본 강좌의목적은확률적그래픽모델의개요를이해하고,이를응용한각종...
}
위 json 데이터 중에서 overview 데이터에 html 형식이 담겨져 있는 것을 확인할 수 있습니다.
KmoocDetailActivity
class KmoocDetailActivity : AppCompatActivity() {
companion object {
const val INTENT_PARAM_COURSE_ID = "param_course_id"
}
private lateinit var binding: ActivityKmookDetailBinding
private lateinit var viewModel: KmoocDetailViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
val courseId = intent.getStringExtra(INTENT_PARAM_COURSE_ID)
if (courseId == null || courseId.isEmpty()) {
finish()
return
}
...
}
private fun setDetailInfo(lecture: Lecture) {
...
binding.webView.loadData(lecture.overview ?: "", "text/html", "UTF-8")
binding.webView.visibility = (lecture.overview?.isEmpty() == false).toVisibility()
}
}
KmoocDetailActivity 에서는 overview 를 확인해서 웹뷰에 로드합니다.
웹뷰의 loadData 로 해당 데이터 문자열을 넘겨주면서, 형식으로 html, 인코딩 UTF-8 을 지정해서 넘겨주기만 하면 웹뷰에 디테일 정보 로드하기 구현 완료입니다.
6.1 Detail activity 뷰 업데이트 과정
KmoocDetailActivity 의 setDetailInfo(lecture: Lecture) 메서드를 통해 뷰에 데이터가 뿌려진다.
KmoocDetailActivity 가 onCreate 시 ViewModelProvider 를 통해 뷰 모델을 받아오며,
viewModel.lecture.observe(this, this::setDetailInfo) 를 통해서 lecture 에 대한 옵저버를 붙여준다. (setDetailInfo 가 Lecture 를 받아 이를 처리하기에 옵저버로 사용 가능한 것)
이후 viewModel.detail(courseId) 이 이루어질 겨우 강좌 id로 부터 lecture 정보가 업데이트 되므로 해당 LiveData 는 옵저버에게 이를 알려 detatil activity 에서 뷰를 갱신할 수 있게된다.
깨알 같은 ProgressBar 처리
viewModel.progressVisible.observe(this) { visible ->
binding.progressBar.visibility = visible.toVisibility()
}
detail 뷰모델이 가지고 있는 progressVisible 이라는 LiveData 를 바탕으로 옵저버를 등록하여 프로그레스바의 표시유무를 셋팅한다.
7. 상단 툴바 (뒤로가기) 클릭시 detail activity 종료하기
AppBarLayout 아래 Toolbar 툴바를 누르면 detail activity 를 종료하기 위해 아래와 같이 구현해줍니다.
class KmoocDetailActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
binding.toolbar.setNavigationOnClickListener { finish() }
...
}
8. 강좌 item 클릭 시 detail activity 실행 시키기
기본적으로 제공되는 코드이다. 이해하고 알아두면 좋을 것으로 보여 이또한 복습해두도록 하겠습니다.
class KmoocListActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
val adapter = LecturesAdapter()
.apply { onClick = this@KmoocListActivity::startDetailActivity }
binding.lectureList.adapter = adapter
...
}
private fun startDetailActivity(lecture: Lecture) {
startActivity(
Intent(this, KmoocDetailActivity::class.java)
.apply { putExtra(KmoocDetailActivity.INTENT_PARAM_COURSE_ID, lecture.id) }
)
}
}
리사이클러뷰의 아이템을 클릭하는 경우에 해당 강좌의 detail 화면으로 넘어가야하는데 이를 정해주는 부분이다.
어뎁터 인스턴스를 생성할 때 apply 를 통해서 onClick 프로퍼티를 할당하게됩니다. 이때 startDetailActivity 의 참조를 넘겨 할당합니다.
startDetailActivity 에서는 또한 Intent 에 Extra 데이터로 해당 lecture 의 강좌 ID를 넣어서 전달하게됩니다.
class LecturesAdapter : RecyclerView.Adapter<LectureViewHolder>() {
var onClick: (Lecture) -> Unit = {}
...
}
...
LecturesAdapter 를 확인해보면 onClick 은 위와 같이 정의된 람다입니다.
class KmoocDetailActivity : AppCompatActivity() {
...
companion object {
const val INTENT_PARAM_COURSE_ID = "param_course_id"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
val courseId = intent.getStringExtra(INTENT_PARAM_COURSE_ID)
if (courseId == null || courseId.isEmpty()) {
finish()
return
}
...
Detail Activity 가 실행될 때 Intent의 Extra 를 확인해서 강좌 ID를 가져옵니다. 없을 경우 처리 불가하므로 그대로 종료해줍니다.
9. Date ↔ String 변환 처리
json 데이터로 받아오는 것 중 하나인 Date 정보의 경우 String 형식으로 받아오기 때문에 Date 형식으로 변환 하여 이를 나타내고 싶은 일자 형식으로 다시 뿌려주기 위해 사용할 수 있습니다.
object DateUtil {
fun parseDate(dateString: String): Date {
try {
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'")
return format.parse(dateString)
} catch (e: Exception) {
return Date()
}
}
fun formatDate(date: Date): String {
val format = SimpleDateFormat("yyyy/MM/dd")
return format.format(date)
}
fun dueString(start: Date, end: Date): String {
return "${formatDate(start)} ~ ${formatDate(end)}"
}
}
이 또한 기본적으로 주어지는 코드이며, 해설에는 dueString 이 추가되어 시작일 ~ 종료일을 나타내는 문자열 형식으로 변환해주는 것을 알 수 있습니다.
String 문자열의 경우 SimpleDateFormat 을 활용해서 Date 형식으로 바꿔줄 수 있으며 이때는 parse 를 사용합니다. 반대로 Date 를 String 형식으로 변환할 때는 format() 에 date 를 넘겨 문자열 형식으로 바꿔줄 수 있습니다.
마무리
프로젝트를 전체적으로 살펴보면 Repository 에서는 서버에 저장되어있는 강좌들을 요청해서 데이터를 ViewModel 에서 사용할 수 있게 하고 ViewModel 에서는 이를 받아와 LiveData 형식으로 갖고 있어서 Activity 에서는 이를 관찰해서 적절하게 UI 처리를 해줄 수 있는 것을 알게되었습니다.
또 이미지를 비동기적으로 처리해서 뷰에 보여주는 것과 캐싱을 통해서 같은 일을 두번 하지 않도록 하는 방법도 알게되었습니다.
그리고 무한 스크롤, JSON 파싱, html 데이터 웹뷰에 뿌려주기, Date 형식 처리 등을 해볼 수 있는 좋은 경험이 되는 값진 과제였던 것 같습니다.
안드로이드 개발자로 일하게되는 그날 까지. 물론 그리고 그 이후에도 즐겁게 개발할 수 있는 제가 되길 기원하며 글을 마무리 하도록 하겠습니다. 도움 되셨다면 공감버튼 한번 부탁드릴게요 😊
2021.08.17 - [Android/App] - [Android] 구글맵 API를 사용한 어플 만들어보기 (위치 검색, 현위치)
2021.08.02 - [Android/App] - [Android] 뮤직 플레이어 앱 만들기 with ExoPlayer, Kotlin
2021.07.13 - [Android/App] - [Android] Youtube 앱 만들어보기 (ExoPlayer, MotionLayout)
'Android > App' 카테고리의 다른 글
[Android] 구글맵 API를 사용한 어플 만들어보기 (위치 검색, 현위치) (2) | 2021.08.17 |
---|---|
[Android] 뮤직 플레이어 앱 만들기 with ExoPlayer, Kotlin (2) | 2021.08.02 |
[Android] Youtube 앱 만들어보기 (ExoPlayer, MotionLayout) (2) | 2021.07.13 |
[Android] 에어비앤비 앱 만들어 보기 (네이버 지도 api, mocky 등) (0) | 2021.06.30 |
[Android] 중고 거래 앱 만들기 (중고 물품 등록, 채팅, 로그인) (3) | 2021.06.24 |