우리는 한다, 개발을.

나의 첫 앱 개발

⌛ 12 mins

프롤로그

회사에서 앱 개발을 맡게 되었습니다.

2014년 쯤 대학교 졸업작품으로 앱을 만들어 보긴 했는데 그 뒤로는 거의 경험이 전무 했지만 그래도 경험은 있으니까…?

처음 원본 소스를 받고 분석을 하는데 어떻게 해야될지 감이 잡히지 않았습니다. 예전에 했었던 거긴 한데 8년이 지난 시점에서 다시 해보려니 너무 막막해서

잠시 접고 인터넷 강의를 통해 기초지식을 쌓았습니다. 짧은 시간 내에 개발해야되는 거라 오래 붙들 순 없고 빠르게 필요한 부분들만 골라서 들었습니다.

그렇게 첫 시작을 Flutter 를 선택 했습니다.

Flutter

처음엔 Flutter로 개발하려 했으나 주요 기능인 usageStatsManager 를 사용하기엔 너무 자료가 부족했습니다.

그래서 Android Native 로 개발하기로 했습니다.

JAVA

자! 이제 JAVA로 시작하려고 각을 잡았다. 기존 코드를 참고 하면서 작성해 나가던 중 난관에 봉착했습니다.

기준 코드가 2016년에 작성됐습니다.

즉! 6년의 세월이 흘렀는데 그 사이에 새로 추가된 것도 있고 더이상 지원하지 않는 기능들이 섞여있었습니다.

또한, 구글은 이제 Kotlin 을 밀어주려는 건지 Java 에 대한 샘플은 불친절했습니다.

그래서… 돌고 돌아 결국 Kotlin으로 개발하기로 했습니다.

Kotlin

개발 언어를 몇개 거치다(VB → JAVA → JS, TS ) 보니 옛날 같았으면 멘붕이 왔을텐데

Kotlin을 보고 약간 Typescript와 비슷하다는 느낌을 받았습니다.

그래서 적응 하는데 시간이 오래 걸리진 않았습니다.

kotlin의 변수 선언 키워드는 다음과 같습니다.

자바스크립트 코틀린
let var
const const val, val

js와 정말 비슷해서 크게 헷갈리는 부분은 없었습니다.

메서드 선언 키워드는 다음과 같습니다.

fun testMethod() {
	// TODO: Hi~
}

그 외에 switch-case 는 when 이라는 걸 쓰고 등등 조금씩 다르긴 했습니다.

타입 선언도 똑같습니다.

자바스크립트 코틀린
const userName: string = “Hoho” val userName: String = “Hoho”

js는 세미콜론을 쓰던 안쓰던 상관이 없지만, kotlin은 세미콜론을 생략해도 됩니다.

그래서 가끔 코드 마지막에 세미콜론을 붙이면 지우라고 알림창이 뜹니다.

그리고 Kotlin 으로 개발해야겠다고 생각하게 된 결정적인 이유는 바로 Coroutine 이라는 것 때문 입니다.

기존에는 AsyncTask 라는 기능을 통해 비동기 작업과 UI 작업을 사용 했었는데

2019년에 안드로이드에서 지원 종료를 한다고 발표를 했었고 대신 Coroutine 이라는 걸 사용하라고 권장하고 있습니다.

코루틴이란?

비동기적으로 실행되는 코드를 간소화하기 위해 Android 에서 사용할수 있는 동시 실행 설계 패턴 입니다.

즉, 비동기 작업을 간소화 하기 위한 기능 입니다.

비교 코드

자바

@Override
public int onStartCommand(Intent intent, int flags, int startId) {
    if (mThread == null) {
        mThread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (serviceRunning) {
                    monitor(getApplicationContext());
                    SystemClock.sleep(MCommon.getInstance().interval_thread);
                }
            }
        });
        mThread.start();
    } else if (mThread.isAlive() == false) {
        mThread.start();
    }
    return START_STICKY;
}

코틀린

private suspend fun job() = CoroutineScope(Dispatchers.Main).launch {
    while(isCollecting.value == true) {
        MActivity.showUsageStats(MyClass.applicationContext())
        delay(intervalService.value!!)
    }
}

fun startCollector() = runBlocking {
    job().start()
}

fun stopCollector() = runBlocking {
    job().cancel()
    stopService()
}

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    intent?.let {
			if (isScreenOn) {
	        isCollecting.postValue(isScreenOn)
	        startCollector()
	    }
    }
    return START_STICKY
}

자… 이제 데이터 수집하는 기능은 끝냈습니다.

근데 복병이 하나 더 남아있는 줄은 상상도 못했습니다.

안드로이드 서비스

서비스는 백그라운드에서 오래 실행되는 작업을 수행할 수 있는 애플리케이션 구성 요소이며 사용자 인터페이스를 제공하지 않습니다.

사용자가 다른 애플리케이션으로 전환하더라도 백그라운드에서 계속 실행이 됩니다.

안드로이드의 서비스는 세 가지 가 있습니다.

  • Foreground - 화면에 보여지는 서비스 (음악, 만보기, 운동 앱 등…)
  • Background - 화면에 보여지지 않는 서비스 (파일 압축, 해제, 다운로드)
  • Bound - 클라이언트 ↔ 서버인터페이스 안의 서버

옛날엔 백그라운드에서 어떤 기능을 돌려도 별 문제가 없었는데

API 레벨 26 부터는 시스템 자체적으로 백그라운드 서비스 실행에 대한 제한을 적용한다고 합니다.

앱이 시스템의 리소스를 많이 잡아먹기 때문이라고 합니다.

그냥 백그라운드 서비스를 실행하면 10분 뒤 OS에서 서비스를 종료시켜버립니다.

그래서 죽지 않는 서비스를 만들려면 포그라운드 서비스로 개발해야 한다고 하여 포그라운드 서비스로 전환 했습니다.

포그라운드 서비스

처음에는 포그라운드 서비스를 통해 데이터 수집 서비스를 실행하였더니 메모리 누수로 인해 앱이 자꾸 죽었습니다.

도저히 모르겠어서 이곳 저곳 블로그나 유튜브를 전전하며 코드를 수정했는데

소 뒷걸음으로 개구리 잡는다는 말처럼 어느 순간 메모리 누수 없이 잘 동작했습니다.

차이점은 이정도 인것 같습니다.

기존

companion object {
    val isCollecting = MutableLiveData<Boolean>()
}

private fun initValue() {
    isCollecting.postValue(false)
}

override fun onCreate() {
    super.onCreate()
    initValue()
}

override fun onBind(intent: Intent): IBinder {
    TODO("Return the communication channel to the service.")
}

@RequiresApi(Build.VERSION_CODES.N)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    intent?.let {
        if (isFirst) {
            startForegroundService()
            isFirst = false
        } else {
            // resuming service...
            Log.d("LOGGING", "resuming...")
            startCollector()
        }
    }
    return super.onStartCommand(intent, flags, startId)
}

@RequiresApi(Build.VERSION_CODES.N)
private fun startForegroundService() {
    startCollector()
    isCollecting.postValue(true)
    createNotificationChannel()
}

private fun createNotificationChannel() {
    if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        val channel = NotificationChannel("service", "service", NotificationManager.IMPORTANCE_HIGH)
        channel.lightColor = Color.RED
        channel.enableVibration(true)
        manager.createNotificationChannel(channel)

        val builder = NotificationCompat.Builder(this, "service")
        builder.setSmallIcon(android.R.drawable.ic_menu_search)
        builder.setContentTitle("Service Running...")
        builder.setContentText("Service is Running...")

        val notification = builder.build()
        // set Foreground
        startForeground(10, notification)
    }
}

@RequiresApi(Build.VERSION_CODES.N)
private fun startCollector() {
    CoroutineScope(Dispatchers.Main).launch {
        while(isCollecting.value!!) {
            val currentTime = System.currentTimeMillis()
            val simpleDateFormat = SimpleDateFormat("hh:mm:ss")
            val date = Date(currentTime)
            val time: String = simpleDateFormat.format(date)
            Log.d("LOGGING", "=== $time ===")
            showUsageStats(applicationContext)
            delay(1000L)
        }
    }
}

바꾼 로직

추측이지만 서비스 실행 로직을 companion object 안에 담았더니 메모리 누수 없이 실행이 되는 듯 합니다.

companion object 에 선언된 변수나 메서드들은 싱글톤의 개념 처럼 사용됩니다.

companion object {
    private const val TAG = "LOGGING MDataService"
    val isCollecting = MutableLiveData<Boolean>()
    var isForceSave = MutableLiveData<Boolean>()
    var intervalService = MutableLiveData<Long>()

    private suspend fun job() = CoroutineScope(Dispatchers.Main).launch {
        while(isCollecting.value == true) {

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                MActivity.showUsageStats(MyClass.applicationContext())
            }

            delay(intervalService.value!!)
        }
    }

    fun startCollector() = runBlocking {
        Log.d(TAG, "CALL startCollector")
        job().start()
    }

    fun stopCollector() = runBlocking {
        Log.d(TAG, "CALL stopCollector")
        job().cancel()
        stopService()
    }

    private fun stopService() {
        MDataService().stopSelf()
        MDataService().stopNotification()
    }
}

데이터 수집

데이터 수집 부분은 특별한 내용은 없고 주요 기능은 다음과 같습니다.

  1. 앱 실행시 해당 앱의 고유 ID(패키지 이름)과 화면 이름(액티비티 이름, 클래스 명)을 가져옵니다.
  2. 가져온 이름들을 Room DB(로컬 데이터베이스)에 저장 합니다.
  3. 저장한 데이터는 즉시 서버로 전송 합니다.
  4. 전송된 데이터는 로컬 데이터베이스에서 삭제 합니다.

BroadcastReceiver

Broadcast 라는 키워드를 보면 알 수 있듯이 단말기에 이벤트가 발생할 때 브로드캐스트를 전송 합니다.

다음과 같이 알림을 받고자 하는 이벤트를 추가할 수 있습니다.

val filter = IntentFilter()
filter.addAction(Intent.ACTION_SCREEN_OFF)
filter.addAction(Intent.ACTION_SCREEN_ON)

// Intent에서 this는 현재 context를 의미한다.
val brIntent = Intent(this, MyReceiver::class.java) 
sendBroadcast(brIntent)

다음과 같이 onReceive 에서 이벤트별로 로직을 줄 수 있습니다.

class MyReceiver : BroadcastReceiver() {

    @RequiresApi(Build.VERSION_CODES.Q)
    override fun onReceive(context: Context, intent: Intent) {
        when (intent.action) {
            Intent.ACTION_SCREEN_ON -> { // 화면을 켰을 때
                MDataService.intervalService.postValue(Constants.INTERVAL_SCREEN_ON)
                intervalSeconds = Constants.INTERVAL_SCREEN_ON
                isScreenOn = true
                isCollecting.postValue(isScreenOn)
                isForceSave.postValue(!isScreenOn)
                startCollector()
            }
            Intent.ACTION_SCREEN_OFF -> { // 화면을 껐을 때
                MDataService.intervalService.postValue(Constants.INTERVAL_SCREEN_OFF)
                intervalSeconds = Constants.INTERVAL_SCREEN_OFF
                isScreenOn = false
                isForceSave.postValue(!isScreenOn)
                MActivity.showUsageStats(context)
                initValue()
            }
            Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_PACKAGE_REPLACED -> { 
								// 재부팅 혹은 재설치 됐을 때
                val user = context.getSharedPreferences("user", MODE_PRIVATE)
                val userExist = user.getBoolean("exist", false)
                if (userExist) {
                    isCollecting.postValue(true)
                    MyClass.setContext(context)
                    val i = Intent(context, RestartService::class.java)
                    context.startForegroundService(i)
                }
            }
            else -> {
                Log.d("LOGGING ACTION", "${intent.action}")
            }
        }
    }
}

onReceive 안에 파라미터 값인 context와 intent를 추가로 설명하면

Context란?

Context

  1. (어떤 일의) 맥락, 전후 사정
  2. (글의) 맥락, 문맥

안드로이드 공식문서에 따르면

요약하자면 !

  • 애플리케이션에 대해 현재 상태를 나타냅니다.
  • 활동 및 애플리케이션에 대한 정보를 가져올 수 있습니다.

안드로이드 개발의 거의 모든 곳에서 사용되며 가장 중요한 개념이라 잘못 사용하면 메모리 누수가 쉽게 일어난다고 합니다.

출처:

https://blog.mindorks.com/understanding-context-in-android-application-330913e32514

https://velog.io/@haero_kim/Android-Context-너-대체-뭐야

Intent란?

Intent

  1. [형용사] 강한 관심[흥미]을 보이는, 몰두[열중]하는
  2. [형용사][격식] (특히 남에게 해가 될 일을) 꾀하는[작정한]
  3. [명사][격식 또는 법률] 의도 (=intention)

안드로이드 공식문서에 따르면

뭔가 이해하기 어렵지만 인텐트의 역할을 크게 3 가지로 볼 수 있습니다.

  • 액티비티 시작 다른 화면으로 전환
  • 서비스 시작 백그라운드 작업 수행
  • 브로드캐스트 전달 모든 앱이 수신할 수 있는 메시지 전달.

에필로그

기존에 참고할 만한 소스가 있었고 회사로부터 지원을 일부 받긴 했지만 완전 처음부터 시작하려고 하니 처음에는 정말 막막했습니다.

물어볼 곳이 인터넷밖에 없어서 정말 여기 저기 자료를 마구 찾아다녔습니다. 그동안 웹 개발을 하면서 리소스나 자원이나 성능에 대해 신경을 아얘 안 쓴건 아니지만

모바일 앱 개발을 하게 되면서 성능 최적화나 리팩토링 같은걸 최대한 많이 하려고 노력 했습니다.

이번 프로젝트를 계기로 안드로이드에 흥미가 생겼고 나만의 앱을 출시해 보는건 어떨까 하는 생각도 들었습니다.

또한 JS나 Kotlin 이나 개발하는 방법은 다르지만 결국 프로그래밍의 길은 크게 다르지 않다는 것을 많이 느꼈습니다.