[Android] 안드로이드 개발 레벨업 교과서 정리 #6 다양한 설계 기법 - 기본 구현


출처 : 안드로이드 개발 레벨업 교과서 133~142p


1. 어떤 앱을 만들까?

이번에는 GitHub 웹서비스의 API를 이용하여 앱을 만들어볼 것이다. GitHub에는 새롭게 주목받는 오픈소스 프로젝트가 있다. 이러한 프로젝트의 리포지토리 리스트를 보여주는 앱을 만들어보자.


2. 화면 레이아웃과 기능을 이해하자

각 화면의 기능을 살펴보자.

리포지토리 목록 화면(RepositoryListActivity)

리포지토리 목록 화면에는 다음과 같은 기능이 있다.

  • 깃허브의 API에 접근해 지정된 프로그래밍 언어의 프로젝트 리포지토리 목록을 가져온다.
  • 프로그래밍 언어는 변경할 수 있고, 변경되면 목록을 갱신한다.
  • 리포지토리 목록의 각 항목을 탭하면 상세 화면으로 이동한다.


상세 화면(DetailActivity)

상세화면은 리포지토리 목록 화면에서 선택된 리포지토리의 데이터를 API로 가져와서 표시한다.

프로필 사진이나 리포지토리 제목을 클릭하면 해당 리포지토리의 url을 웹 브라우저에 표시한다.


3. 구현 방법을 확인하자

전체 프로젝트 코드는 https://github.com/Onedelay/GithubRepo/tree/master/app_original 를 참고하면 된다. (기존 프로젝트는 자바로 작성되어있고, 여기를 참고하면 된다.)

한 프로젝트에 여러개의 모듈을 생성해서 개발할 수 있다고는 들어만봤는데, 책에서도 그렇게 구현되어있길래 한번 해봤다. 모듈 추가는 간단하게 File -> New -> New Module 로 생성할 수 있다. 처음 프로젝트를 생성할 때 app 모듈이 기본적으로 생성되어있고, MVP, MVVM 패턴 예제를 위한 2개의 모듈을 추가했다. 모듈의 이름은 생성 후 바꿔버렸고, Configuration에 있는 이름도 따로 바꿔주었다.

모듈을 생성하고, edit configuration 에서 Name 을 바꾸면 구조는 이렇게 완성된다. 프로젝트 폴더를 확인해보면 각 모듈별로 폴더가 생성되어있는 것을 알 수 있다.


4. 리포지토리 화면을 이해하자

RepositoryListActivity 에서 하는 일

  1. 뷰 초기화
  2. 리사이클러뷰 아이템 클릭 이벤트 구현
  3. 스피너 선택 이벤트 구현
  4. API 요청


전체적인 흐름은 다음과 같다.

  1. 시작할 액티비티의 onCreate()에서 setupViews() 메서드를 호출한다. setupViews() 메서드 안에서 각 뷰의 초기화를 진행하며, 이 때 액티비티는 리스트 아이템의 클릭 이벤트를 받을 수 있도록 RepositoryAdapter.OnRepositoryItemClickListenerimplements한다.
  2. GitHubService를 이용해 GitHub API에 접근하여 RepositoryListActivity가 데이터를 수신한다.
  3. RepositoryAdapter에서 RepositoryListActivity로부터 데이터를 전달받아 리사이클러뷰에 표시한다.


아래 메서드는 뷰들을 초기화하는 과정이 포함되어있으며, RepositoryListActivityonCreate()메서드에서 호출한다.

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
private fun setupViews() {
// 툴바 설정
setSupportActionBar(toolbar)

// RecyclerView
recycler_repos.layoutManager = LinearLayoutManager(this)
recycler_repos.adapter = repositoryAdapter

// Spinner
val adapter = ArrayAdapter<String>(this, android.R.layout.simple_spinner_item)
adapter.addAll("java", "kotlin", "objective-c", "swift", "groovy", "python", "ruby", "c")
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
language_spinner.adapter = adapter
language_spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onNothingSelected(p0: AdapterView<*>?) {
// Do nothing
}

override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
// 선택시 뿐만 아니라 처음에도 호출 됨
val language = language_spinner.getItemAtPosition(position) as String
loadRepositories(language)
}

}
}

스피너의 아이템 목록을 선택하면, loadRepositories() 메서드를 이용하여 리포지토리 목록을 요청한다.

loadRepositories() 메서드로 API에 접근하고, 리포지토리 목록 데이터를 가져온다. 이 메서드에 가장 핵심적인 로직이 포함되어있다. 처리 흐름은 다음과 같다.

  1. progress bar를 표시한다.
  2. 1주일 전 날짜를 구한다.
  3. 1주일 전 날짜와 Spinner로 선택한 프로그래밍 언어로 API에 접근한다.
  4. API의 응답 결과는 onNext()에서 수신한다.
  5. Progress bar를 숨긴다.
  6. 어댑터에 데이터를 추가하고, Recyclerview를 갱신한다.
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
/**
* 지난 1주일간 만들어진 라이브러리의 인기순으로 가져온다
* @param language 가져올 프로그래밍 언어
*/
private fun loadRepositories(language: String) {
// 로딩 중이므로 진행바 표시
progress_bar.visibility = View.VISIBLE

// 일주일전 날짜의 문자열
val calendar = Calendar.getInstance()
calendar.add(Calendar.DAY_OF_MONTH, -7)
val text = android.text.format.DateFormat.format("yyyy-MM-dd", calendar).toString()

// 서버 요청
val application = application as GitHubReposApplication

// 지난 일주일간 생성되고 언어가 language 인 것을 요청한다
val observable = application
.gitHubService
.listRepos("language:$language created:>$text")

// IO 스레드로 통신하고, 메인스레드에서 결과를 수신하도록 한다
observable.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(object : Subscriber<GitHubService.Companion.Repositories>() {
override fun onNext(repositories: GitHubService.Companion.Repositories?) {
// 로딩이 끝났으므로 진행바를 표시하지 않는다
progress_bar.visibility = View.GONE
// 가져온 아이템을 표시하고자 RecyclerView 에 아이템을 설정하고 갱신한다
repositoryAdapter.setItemsAndRefresh(repositories?.items ?: listOf())
}

override fun onCompleted() {
// Do nothing
}

override fun onError(e: Throwable?) {
// 통신 실패 시에 호출된다
// 여기서는 스낵바를 표시한다(아래에 표시되는 바)
Snackbar.make(coordinator_layout, "읽어올 수 없습니다.", Snackbar.LENGTH_LONG)
.setAction("Action", null).show()
}
})
}

RxJava 개념을 잘 모르지만, 비동기로 주고받을 때 처리 흐름과 스레드를 제어하기가 편리하다고 얼핏 들은 것 같다. (Rx없이도 CallBack 으로 편하게 구현할 수 있을 법 한데…. 그냥 예제를 따라봤다.)



RxJava 깨알 정리 - Observable Utility Operators

참고 : http://reactivex.io/documentation/operators.html

  • observeOn : 옵저버가 어느 스케줄러 상에서 Observable을 관찰할지 명시한다

  • subscribeOn : Observable을 구독할 때 사용할 스케줄러를 명시한다

  • subscribe : Observable이 배출하는 항목과 알림을 기반으로 동작한다

아무리 봐도 이해가 잘 안된다. RxJava는 나중에 열심히 공부하는 걸로…



다음으로 API에 접근할 인터페이스다. 모든 액티비티에서 서버 요청 인스턴스를 이용할 수 있도록, Application에서 초기화 할 것이다.

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
/**
* Retrofit 으로 Github API 를 이용하기 위한 클래스
*/
interface GitHubService {
/**
* GitHub 의 리포지토리 검색 결과를 가져온다
* https://developer.github.com/v3/search/
* @param query GitHub API 로 검색할 내용
* @return API 액세스 결과 취득 후의 콜백으로서 SearchResponse 를 가져올 수 있는 RxJava 의 Observable 로 반환
*/
@GET("search/repositories?sort=stars&order=desc")
fun listRepos(@Query("q") query: String): Observable<Repositories>

/**
* 리포지토리 상세 내역을 가져온다
* https://developer.github.com/v3/repos/#get
* @return API 액세스 결과 취득 후의 콜백으로서 RepositoryItem 을 가져올 수 있는 RxJava 의 Observable 로 반환
*/
@GET("repos/{repoOwner}/{repoName}")
fun detailRepo(@Path(value = "repoOwner") owner: String, @Path(value = "repoName") repoName: String): Observable<RepositoryItem>

companion object {
/**
* API 액세스 결과가 이 클래스에 들어온다
* Github 의 리포지토리 목록이 들어와있다.
* @see GitHubService#listRepos(String)
*/
data class Repositories(val items: List<RepositoryItem>)

/**
* API 액세스 결과가 이 클래스에 들어온다
* GitHub 의 리포지토리 데이터가 들어와 있다
* @see GitHubService#detailRepo(String, String)
*/
data class RepositoryItem(
val description: String,
val owner: Owner,
val language: String,
val name: String,
val stargazers_count: String,
val forks_count: String,
val full_name: String,
val html_url: String
)

/**
* GitHub 의 리포지토리에 대한 오너의 데이터가 들어와 있다
* @see GitHubService#detailRepo(String, String)
*/
data class Owner(
val received_events_url: String,
val organizations_url: String,
val avatar_url: String,
val gravatar_id: String,
val gists_url: String,
val starred_url: String,
val site_admin: String,
val type: String,
val url: String,
val id: String,
val html_url: String,
val following_url: String,
val events_url: String,
val login: String,
val subscriptions_url: String,
val repos_url: String,
val followers_url: String
)
}
}

Gson 컨버터를 이용해 json을 클래스로 변환할 것이기 때문에, API 요청 응답으로 오는 json 형식에 맞추어 클래스를 구현해주어야한다. 확실히 코틀린으로 작성하니 보일러플레이트 코드없이 깔끔한 것 같다.

그리고 json 형태를 보아하니, [{repository, owner}, {repository, owner}, …] 형태로 오는 것 같다.

listRepos() 메서드로 가져온 Observable의 인스턴스에 subscribe하면 API 접근이 수행된다. 서버로부터 결과를 받으면, RxJava의 메커니즘으로 onNext() 메서드가 호출된다. (오류가 나면 onError() 메서드가 호출 될 것이다.) onNext() 메서드에서 progress bar 표시를 숨기고, RecyclerViewAdapter에 서버로부터 받은 데이터를 설정한다.

다음으로 리포지토리 표시를 위한 RecyclerView.Adapter 클래스이다.

아이템이 클릭되면, 새로운 브라우저 앱을 띄우도록 이벤트를 전달받을 인터페이스가 선언되어있다. (클릭 이벤트는 액티비티로부터 수신하므로, 액티비티에서 구현한다.)

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
/**
* RecyclerView 에서 리포지토리의 목록을 표시하기 위한 Adapter 클래스
* 이 클래스로 RecyclerView 의 아이템의 뷰를 생성하고, 뷰에 데이터를 넣는다
*/
class RepositoryAdapter(private val onRepositoryItemClickListener: OnRepositoryItemClickListener) :
RecyclerView.Adapter<RepoViewHolder>() {
private var items: List<GitHubService.Companion.RepositoryItem> = listOf()

/**
* 리포지토리의 아이템이 탭되면 호출
*/
interface OnRepositoryItemClickListener {
fun onRepositoryItemClick(item: GitHubService.Companion.RepositoryItem)
}

/**
* 리포지토리의 데이터를 설정해서 갱신한다
*/
fun setItemsAndRefresh(items: List<GitHubService.Companion.RepositoryItem>) {
this.items = items
notifyDataSetChanged()
}

private fun getItemAt(position: Int) = items[position]

/**
* RecyclerView 의 아이템 뷰 생성과 뷰를 유지할 ViewHolder 를 생성
*/
override fun onCreateViewHolder(parent: ViewGroup, position: Int) = RepoViewHolder.create(parent)

/**
* onCreateViewHolder 로 만든 ViewHolder 의 뷰에
* setItemsAndRefresh(items)으로 설정된 데이터를 넣는다
*/
override fun onBindViewHolder(holder: RepoViewHolder, position: Int) {
val item = getItemAt(position)
holder.bind(item)
// 뷰가 클릭되면 클릭된 아이템을 Listener 에게 알린다
holder.itemView.setOnClickListener {
onRepositoryItemClickListener.onRepositoryItemClick(item)
}
}

override fun getItemCount() = items.size
}

다음은 뷰홀더 클래스다.

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
class RepoViewHolder private constructor(itemView: View) : RecyclerView.ViewHolder(itemView) {

companion object {
fun create(parent: ViewGroup) =
RepoViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.repo_item, parent, false))
}

fun bind(item: GitHubService.Companion.RepositoryItem) {
itemView.repo_name.text = item.name
itemView.repo_detail.text = item.description
itemView.repo_star.text = item.stargazers_count

Glide.with(itemView.context)
.asBitmap()
.load(item.owner.avatar_url)
.into(object : BitmapImageViewTarget(itemView.repo_image) {
override fun setResource(resource: Bitmap?) {
// 이미지를 동그랗게 만든다
val circularBitmapDrawable: RoundedBitmapDrawable =
RoundedBitmapDrawableFactory.create(itemView.context.resources, resource)
circularBitmapDrawable.isCircular = true
itemView.repo_image.setImageDrawable(circularBitmapDrawable)
}
})
}
}


DetailActivity 는 그냥 메인 리스트에서 아이템 하나를 클릭하면 이동된다.

여기서 프로필 사진이나, 리포지토리 이름을 클릭하면 해당 리포지토리 주소로 웹 앱을 통해 이동된다.


5. 고찰과 깨달음

현재 코드로는 RepositoryListActivity의 구현이 100줄 정도이므로 문제가 없어보인다. 실제로 이 정도 크기라면 이렇게 설계하는 것도 선택지로서 충분히 고려할 수 있다. 하지만 이 방침을 그대로 유지하면 액티비티가 거대해질 가능성이 있다. UI 로직과 View 조작이 함께 있는 상태이므로 현재 상태로도 전망이 좋지 않은 코드라고 말할 수 있다.

Share