Tempo Di Valse

[Android] 다음 주소 찾기 서비스를 편하게 사용하기 본문

개발/Android

[Android] 다음 주소 찾기 서비스를 편하게 사용하기

TempoDiValse 2023. 11. 23. 12:52

오랜만에 포스팅을 시작하기 전에, 해당 포스팅과 관련된 Git 주소를 올려보도록 하겠다.

 

GitHub - TempoDiValse/AddressFinder

Contribute to TempoDiValse/AddressFinder development by creating an account on GitHub.

github.com

 

다음 API 를 이용한 주소찾기는 여러번 만들어 봤어서 만드는 데에 큰 문제는 없었는데, 한번 이런 생각을 해봤다.

 

"그냥 하나 만들어 놓고서 다른데에 필요하면 갖다 쓰면 되지 않을까?🤔🤔"

 

그래서, iOS 는 놓은 지도 오래고 잡을 일도 없을 것 같아서 Android 에서 그냥 액티비티만 가져다가 쓸 수 있도록 만들어 보았다. 소스 내에서는 Manifest 에다가 Activity 등록하는 일 이외에는 없는 듯 하고, Intent 를 해서 ActivityForResult 콜백을 register 하고 받아와서 입력처리하고... 등등 하는 것을 하나의 액티비티내에서, 그저 사용하는 부분에서는 액티비티 실행하고 콜백만 받을 수 있도록 만들어보았다.

 

더 알기 쉽게 사용자는,

AddressFinder.open { b ->
    val zipCode = b.getString(AddressFinder.ZIPCODE)
    val address = b.getString(AddressFinder.ADDRESS)

    println("주소: [$zipCode] $address")
}

 

이것만 사용하면 끝이다. 물론 사용하는까지는 몇 줄 정도의 셋팅이 필요하긴하다.


그럼 이 간단한 프로젝트를 개발한 프로세스에 대해 하나하나 설명을 붙여보도록 하겠다.

 

먼저, 우편번호찾기를 띄울 액티비티를 준비한다. XML 에 WebView 는 필수로 들어가야 하고 나머지는 만들고자 하는 UI 대로 구성하면 될 것이다. 나의 경우에는 예의상 필요한 헤더와 WebView 정도만 했다. Submit 용도의 버튼도 필요없다.

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="?actionBarSize"
        android:orientation="horizontal"
        android:gravity="center_vertical"
        android:paddingHorizontal="10dp"
        android:elevation="1dp">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="주소찾기"
            android:textSize="20sp"
            android:textColor="#333333"
            android:textStyle="bold" />

    </LinearLayout>

    <WebView
        android:id="@+id/webView"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />
</LinearLayout>

 

참 깔끔하다. 액티비티가 준비 되었으면, 다음 주소찾기 페이지를 호출할 HTML 파일을 구성하도록 한다. 다음 주소찾기 페이지 HTML 파일은 인터넷에 검색해보면 쉽게 찾을 수 있다. 단, 해당 소스에서도 추가해야 할 몇 가지 사항들이 있는데 바로 JavascriptInterface 를 쓰기 위해서 "브릿지 이름" 을 정의해 주어야 한다는 것이다.

 

그래서 나는 HTML 에 다음의 스크립트를 추가해 주었다.

<script type="text/javascript">
    const BRIDGE_CODE = "address_finder";

    // App 에 주소및 우편번호 데이터를 전송하도록 한다.
    function send(address, zipCode){
        window[BRIDGE_CODE].result(address, zipCode);
    }
</script>

 

이게 왜 이래야 되는 지는 다음의 포스팅을 확인해 보도록 하자

 

[Android] Web 에서 App 으로 데이터를 받아보자

Javascript랑 WebView를 연동하다보면 데이터를 주고 받는거는 필수적인 사항이다. 특히 프레임워크를 사용하는 사람이 아니라면 일일히 Web과 App에 대해 통신 하는 것을 다 짜야되는 수고가 있다. 그

tempodivalse.tistory.com

그래서, 사용자가 WebView 에 띄워진 주소찾기 화면에서 주소를 찾고, 해당되는 주소를 클릭하게 되면 send() 메소드를 호출하도록 다음 API 콜백 소스를 변경하도록 한다.

 

HTML 을 준비한 후에는 Android Asset 폴더에 집어넣을 수 있도록 한다. 서버와 통신한다면 서버에 페이지 하나를 넣어서 사용할 수 있겠지만 이 경우에는 단독으로 Android 환경안에서 돌아가야 하도록 만들었다.

 

Git 예제에서는 assets/html 폴더에 address.html 라고 넣어주었다.

 

이제 WebView 에서 해당 파일을 load 시키면 완료가 되는 일인데... 예전의 경우에는 다음의 방법으로 Asset 에 있는 HTML 파일을 작동 시켰었다.

webView.loadUrl("file:///android_asset/index.html")

 

하지만 화면은 로드가 되지만 정작 주소를 찾고나서 버튼을 누르게 되면,

 

 

 

[ Failed to execute 'postMessage' ...... ] 라는 에러 문구를 뱉으며 이 후의 단계를 넘어가지 않게 된다. 이 문제의 원인과 해결법은 다음우편번호 Git Issue 에도 올라와 있다.

 

oncomplete 콜백이 작동하지 않습니다. · Issue #642 · daumPostcode/QnA

안드로이드 환경에서 최신 가이드에 있는 iframe을 이용하여 레이어 띄우기 예제 코드를 그대로 복사 붙여넣기 하였습니다. 우편 번호 검색은 정상적으로 작동하나, 검색 결과를 클릭하였을 때는

github.com

결론은 "웹 서버에서 띄워야 한다." 인데, 지금의 상황에서는 웹 서버를 띄우는 것이 아니기 때문에 좋은 제안은 되지 않는다.

그래서 file:// 을 이용한 Asset 로드는 할 수 없게 되었는데, 다행하게도 다른 공식적인 우회방법으로 작동을 시킬 수 있게 되었나니!! 그것은 WebViewAssetLoader 를 사용하는 것이다.

 

해당 기능은 일반 Android 라이브러리에는 들어있지 않고, 추가적으로 "androidx.webkit:webkit" 을 Gradle 에 추가 해야 사용할 수 있다. 자세한 사항은 다음의 WebViewAssetLoader 에 대한 공식 문서를 확인해보면 되고 (영어 번역 지원 X),

 

WebViewAssetLoader  |  Android Developers

androidx.appsearch.builtintypes.properties

developer.android.com

 

클래스의 요점은 Asset 에 있는 HTML 을 불러오는데, 해당 HTML 을 시스템 내에서 가상 도메인을 부여하고 그 환경 내에 있는 웹 페이지 인 것 처럼 처리시켜준다는 것이다. 공식문서에서는 appassets.androidplatform.net 라는 도메인 값 (해당 도메인은 기본값이다.) 으로 예시를 두고 있으며, 페이지를 로드할 때 다음처럼 로드를 할 수 있게 만들어 준다.

webView.loadUrl("https://appassets.androidplatform.net/assets/index.html")

 

그렇다면 사용하는 방법은 어떻게 될까. 정의할 프로세스는 다음처럼 될 것이다.

- WebViewAssetLoader 의 기본 옵션을 설정하는 Builder 클래스 정의
- Asset 에서 불러온 페이지가 이동을 잘할 수 있도록 WebViewClient 정의

 

먼저 Builder 클래스를 보도록 한다. 하는 건 별거 없고 엄청 짧다.

companion object {
    //....
    private const val DOMAIN = "address.finder.net" // 로컬 가상 도메인
}

override fun onCreate(...){
    // ....
    
    val assetLoader = WebViewAssetLoader.Builder()
                .addPathHandler("/assets/",
                    WebViewAssetLoader.AssetsPathHandler(this)
                ) // Asset 경로를 지정해준다. 시작과 마지막에 슬래쉬('/') 를 쓰지 않으면 익셉션을 뱉는다
                .setDomain(DOMAIN) // 도메인 정의를 하지 않으면, "appassets.androidplatform.net" 가 디폴트
                .build()
}

 

자세하게 하나하나 어떻게 동작하는 지는 테스트 해보진 않았지만

 

첫번째로, .addPathHandler 를 통해서 가상의 Path 를 지정하고, 해당 Path 를 통해서 assets 폴더에 접근할 수 있도록 해주는 것 같았다(Webpack 같은 느낌이다.). 예시 소스에서는,

"/assets/" 라고 지정해놓았기 때문에,
=> https://appasets.androidplatform.net/assets/html/address.html

"/pages/" 라고 지정한다면,
=> https://appasets.androidplatform.net/pages/html/address.html

"/" 라고 지정한다면,
=> https://appasets.androidplatform.net/html/address.html

 

이렇게 설정을 할 수 있다. 주의 해야 할 점은. path 앞 뒤에 슬래쉬가 꼭 붙어있어야 한다는 것이다. 그렇지 않으면 WebView 를 불러오기 전에 익셉션을 뱉어 앱이 죽게된다.

 

두번째로 setDomain 설정이 있는데, 이 설정을 하지 않게되면 appassets.androidplatform.net 를 사용해야 한다. 하지만 그게 싫거나 다른 이유에서 변경이 필요하다면, setDomain 을 이용하여 변경을 시켜주도록 한다. 그래서 예시 소스대로 하게 된다면,

https://address.finder.net/assets/html/address.html

으로 페이지를 로드를 시킬 수 있다.

 

이렇게 빌드 클래스를 완성시키면, WebViewClient 를 정의하여 WebViewAssetLoader 로 불러온 페이지의 라우팅을 도와주어야 한다. 예제 소스에서는 공식 문서에 나온 대로 WebViewClientCompat 을 사용하였고, 그 방식은 공식문서와 동일하기 때문에 참고 하도록 한다.

 

여기까지 설정이 완료되었다면 webView.loadUrl 을 통해서 페이지를 열 수 있을 것이고, 주소를 찾게 되면 JavascriptInterface 를 통해서 Android 안으로 쏴줄 것이다.

 

JS 에서 window[BRIDGE_CODE].result(address, zipcode) 로 쏴주었기 때문에 JavascriptInterface 클래스 에서는 다음의 처리를 하면 WebView 페이지 구성은 완료가 된다.

private class JavascriptInterface(private val activity: Activity) {

    @android.webkit.JavascriptInterface
    fun result(address: String, zipCode: String){
        val intent = Intent().apply {
            putExtra(ADDRESS, address)
            putExtra(ZIPCODE, zipCode)
        }

        with(activity){
            setResult(RESULT_OK, intent)
            finish()
        }
    }
}

 

Activity 에서는 사용자가 찾은 [주소, 우편번호] 데이터만 찾고서 넘겨줄 것이기 때문에 Intent 에 데이터를 넣는 것 이외에 별 다른 처리를 하지 않았다. 마지막으로는 액티비티 외부로 던져줄 처리만 남았다.

 

보통 B 액티비티에서 A 액티비티로 데이터 전달할 때에는 onActivityResult 를 통해서 전달이 되었지만, 지금은 메소드 주체들이 분리 되지 않고 하나의 메소드에서 Open 과 Completion 을 해야 하기 때문에 다음의 방법은 사용하지 않았다. 게다가 onActivityResult 로 받는 방식도 Deprecated 되는 상황이라서 이래저래 우회 하는 방식인, ActivityResult 기능을 를 사용하게 되었다.

 

ActivityResult 기능도 기본 라이브러리는 아니고, Gradle 에서 "androidx.activity:activity-ktx" 를 추가 해주어야 사용할 수 있는 기능이다. 이 기능에 대한 자세한 내용은 다음의 공식문서를 확인하도록 하자.

 

활동에서 결과 가져오기  |  Android 개발자  |  Android Developers

활동에서 결과 가져오기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 앱 내에서든 다른 앱에서든 다른 활동을 시작하는 것은 단방향 작업이 아니어도 됩

developer.android.com

이 기능을 보통의 경우에서 사용하게 된다면,

1. A 액티비티에 ActivityResultContract 를 정의한다. ActivityResultContract 는 이동할 액티비티에 대한 Intent 정의와 콜백 받을 결과값에 대해 정의할 수 있다.
2. A 액티비티에 정의한 Contract 를 registerForActivityResult 를 통해서 등록한다. 이 때, ActivityResultLauncher 를 반환한다.
3. A 액티비티에서 ActivityResultLauncher 를 통해서 액티비티를 실행한다.

 

이런 프로세스 방식으로 흘러가는데 예제의 주체는 거의 A 액티비티 내에서 이뤄지고 있다. 하지만, ActivityResult 가 override 하는 메소드가 아니라 객체이기 때문에, 어디에 위치해 있어도 작동할 수 있다는 것을 인지할 수 있었다. 그렇기 때문에, 콜백만 받을 액티비티 A 에서는 아무것도 하지 않고, 주소찾기를 하는 주체인 B 액티비티에서 모든 것을 처리하도록 구성을 할 수 있게 되었고, 다음과 같은 프로세스를 생각하게 되었다.

1. B 액티비티에 ActivityResultContract 가 미리 정의 되어있다. ActivityResultContract 는 어느 액티비티에서도 동일하게 데이터를 전달할 수 있도록 Intent 를 구성하고, 콜백 형식도 동일하게 전달 해줄 수 있도록 정의한다.
2. B 액티비티에 register 메소드를 정의했다. 이 메소드는 주체가 되는 Activity 나 Fragment 를 받을 수 있게 했으며 해당 주체에 ActivityResultContract 를 등록할 수 있도록 만든다. 해당 메소드는 static 이다.
3. B 액티비티에 unregister 메소드를 정의했다. 이 메소드는 더 이상 부를 일이 없는 상황인데도 메모리에 ActivityResult 관련 객체들이 남아있지 않도록 한다. 해당 메소드는 static 이다
4. 주체가 되는 곳 에서는 B 액티비티의 open 메소드를 통해서 주소찾기를 호출 할 수 있도록 한다. 이 때, 모든 작업 후 콜백 받을 수 있도록 Annoymous function 을 파라미터로 넘겨주도록 한다. open 메소드는 static 이다.

 

주체가 되는 Activity 나 Fragment 에서는 registerForActivityResult 를 통해서 ActivityResultContract 를 등록하게 되는데, 등록함과 동시에 onActivityResult 의 역할을 하는 콜백 메소드를 함께 정의해 주어야 한다. 그렇기 때문에 B 액티비티의 register 메소드를 통해서 해당 기능을 B 액티비티에서 대신 할 수 있도록 했으며, 콜백 메소드는 미리 정의 하지 않아도 open 메소드를 통해서 변수형태로 콜백 객체를 넘겨주어서 작업 완료 후에 호출 할 수 있도록 한 것이다. 이 모든 상황은 전부 static 영역 내에서 이뤄지기 때문에 메모리에 불필요한 것들이 남을 수 있어서 unregister 를 통하여 static 내에서 정의되었던 것들에 대해서 전부 null 처리를 시키는 것이다.

 

설명은 길었고 그래서 소스로 확인해보면,

companion object {
        //...
        
        private var launcher: ActivityResultLauncher<Bundle>? = null
        private val contract: ActivityResultContract<Bundle, Bundle> get() = 
            object: ActivityResultContract<Bundle, Bundle>(){
                override fun createIntent(context: Context, input: Bundle): Intent = Intent(ACTION)
                override fun parseResult(resultCode: Int, intent: Intent?): Bundle =
                    when (resultCode) {
                        RESULT_CANCELED -> Bundle.EMPTY
                        else -> intent?.extras ?: Bundle.EMPTY
                    }
            }

        private var action: ((Bundle) -> Unit)? = null

        /**
         * Java 환경에서 호출하는 용도
         *
         * @param onComplete 콜백 받는 메소드
         */
        @JvmStatic
        fun open(onComplete: Consumer<Bundle>) {
            callee(onComplete::accept)

            openInternal()
        }

        /**
         * Kotlin 환경에서 호출하는 용도
         *
         * @param onComplete 콜백 받는 메소드
         */
        fun open(onComplete:(Bundle) -> Unit){
            callee(onComplete)

            openInternal()
        }

        private fun openInternal() {
            launcher?.launch(Bundle())
        }

        @JvmStatic
        fun register(fragment: Fragment){
            launcher = fragment.registerForActivityResult(contract) { b -> action?.invoke(b) }
        }

        @JvmStatic
        fun register(activity: ComponentActivity){
            launcher = activity.registerForActivityResult(contract) { b -> action?.invoke(b)}
        }

        @JvmStatic
        fun unregister(){
            action = null
            launcher = null
        }

        private fun callee(func: (Bundle) -> Unit){ action = func }
}

 

여기서 register() 를 사용할 때의 주의할 점이 있는데, registerForActivityResult 라는 메소드 자체가 선언될 때의 Lifecycle 위치에 맞춰야 하는 특성이 있어서 onStart 시점에 위치를 시켜야 하는 부분이 있다. unregister() 같은 경우에는 Destroy 되는 시점에 아무때나 사용해도 상관 없을 듯 하다.


여기까지가 큰 설명이었고, 전체 소스는 맨 상단에 있기 때문에 Git 을 확인 해보면 좋을 것 같다.

 

 

반응형
Comments