생각해보다 다들 사람의 신체를 파악해 자세를 추정하는 기술을 어플리케이션에 적용해보고 싶어한다는 것을 알게되었다. 따라서, 스타트 때는 모션인식을 통한 K-pop 포인트 안무 연습 & 녹화 서비스를 진행하기로 하였었다. 하지만 춤 동작은 가지수가 너무 다양해서 모델을 학습시키기에 어려움을 겪었었다.
따라서 신체추정이라는 키워드는 유지한채로, 동작의 가짓 수가 조금 더 적고 정형화되어있으며, 모델을 학습시키기위한 데이터베이스가 조금 더 많은 쪽으로 주제를 변경하기로 하였다.
따라서 변경된 주제는, 시각 장애인을 위한 요가 어플리케이션👟이 되었다.
안드로이드 스튜디오에서 코틀린 언어를 사용해서 어플리케이션을 개발하기로 하였다.
안드로이드 스튜디오를 공부하다 보면, 생명주기(LifeCycle)가 굉장히 중요하다는 말을 듣게 되는데 처음에는 그런가보다~하고 넘어갔던 것들이 이번 개발을 통해서 절실히 느껴졌다. 이전 포스트에서도 생명주기를 간단히 설명하고 넘어갔지만, 이것이 어떤 방식으로 어플리케이션 동작 방식을 만드는지는 깊게 이해하지 못하고 개발을 시작했고, 이 때문에 요가 클래스를 구성하는 기능을 만들 때 많이 애를 먹었다.
따라서, 생명주기에 대한 개념을 확실히 하고, 다음 추가된 기능들에 대한 설명을 하려고 한다.
- 생명주기란?
- 쓰레드란?
- 생명주기 ,쓰레드를 사용해서 요가 클래스 구성하기
❤️시작해보자❤️
앱이 생성되고 종료될 때까지의 단계적인 과정을 말한다. 앱의 상태 변화와 사용자와의 상호작용에 따라 다양한 이벤트와 메서드를 호출하게되는데, 이도 포함한다.
앱의 생명주기를 실제 메모 어플리케이션을 가정해서 생각해보자.
👉 앱을 처음 실행하면,
onCreate() 메서드가 호출되고, 앱의 초기 설정을 수행하고 사용자 인터페이스를 설정하게 된다.
onStart() 메서드가 호출되고, 앱이 사용자에게 보여지기 직전에 필요한 초기화 작업을 수행한다.
onResume() 메서드가 호출된다. 앱이 전면에 나타나고 사용자가 사용할 수 있는 상태가 된다.
👉 사용자가 메모를 작성하고 저장한 후 홈 버튼을 눌러 앱을 나가게되면,
onPause() 메서드가 호출되고, 현재 액티비티가 일시 중지되고 앱이 화면에서 가려지게되는데, 작성한 메모를 저장하거나 앱 상태를 유지를 해야한다.
onStop() 메서드가 호출되고, 앱이 더 이상 사용자에게 보이지 않는 상태가 된다.
👉 사용자가 앱을 다시 실행하여 메모를 수정하려고 하면,
onRestart() 메서드가 호출되고, 앱이 중단된 상태에서 다시 시작되는 경우에만 호출된다. 이 때, 이전에 작성한 메모를 불러올 수 있다.
onStart() 메서드가 호출되고, 앱이 사용자에게 보여지기 직전에 필요한 초기화 작업을 수행한다.
onResume() 메서드가 호출되고, 앱이 전면에 나타나고 사용자와 상호작용할 준비가 된 상태가 된다.
👉 사용자가 앱을 완전히 종료하면,
onPause() 메서드가 호출되고,
onStop() 메서드가 호출되고,
onDestroy() 메서드가 호출된다. 앱이 완전히 종료되기 전에 호출되며, 여기에서 앱이 사용하는 모든 리소스를 해제하고 정리하는 작업을 수행해야 한다.
😊아래는 예시 코드이다.
onCreate부터 onDestroy까지 Log를 찍어서 Logcat을 통해 확인해보기
class MainActivity : AppCompatActivity() {
companion object {
private const val TAG = "MainActivity"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
Log.d(TAG, "onCreate() called")
}
override fun onStart() {
super.onStart()
Log.d(TAG, "onStart() called")
}
override fun onResume() {
super.onResume()
Log.d(TAG, "onResume() called")
}
override fun onPause() {
super.onPause()
Log.d(TAG, "onPause() called")
}
override fun onStop() {
super.onStop()
Log.d(TAG, "onStop() called")
}
override fun onDestroy() {
super.onDestroy()
Log.d(TAG, "onDestroy() called")
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
Log.d(TAG, "onSaveInstanceState() called")
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
Log.d(TAG, "onRestoreInstanceState() called")
}
}
위의 로그는 앱을 중단하고, 다시 들어갔을 때 찍힌 로그이다.
👉🏻 생명주기에 기능 넣어보기
간단한 메모장 어플의 기능을 할 수 있는데,
여기서는 두 개의 액티비티를 사용해서 한 액티비티에서 텍스트를 전송하였을 때, 두 번째 액티비티에서 전송한 텍스트를 보여주는 예시를 만들어보았다.
각 생명주기 별로 구별해서 설명해보면,
✨ onCreate
//액티비티가 생성될 때 호출
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
//binding.root는 액티비티의 레이아웃 파일의 최상위 뷰
//해당 레이아웃을 화면에 표시
setContentView(binding.root)
binding.button.setOnClickListener {
// 다음 화면으로 이동
// 1. Intent 객체 생성하여 다음 화면으로 이동할 정보 담기
val intent = Intent(this, SecondActivity::class.java)
// 2. "text"라는 키로 text 변수의 값을 전달
intent.putExtra("text", text)
// 3. startActivity() 메서드를 호출하여 다음 화면으로 이동
startActivity(intent)
}
}
✨ onResume
초기에 어플이 실행될 때, 앱을 잠시 중단했다가 다시 켤 때 호출되는 메서드이다. 따라서 이전에 사용자가 작성했던 메모가 있다면 메모를 바인딩해줘야 한다.
override fun onResume() {
super.onResume()
if (!text.isNullOrEmpty()) {
binding.editText.setText(text)
}
}
✨ onPause
잠시 앱을 중단할 때 불리는 메서드이다. 작성했던 메모를 text 에 담아주게 된다.
override fun onPause() {
super.onPause()
text = binding.editText.text.toString()
}
✨ onRestart
앱을 중단했다가 다시 실행했을 때, 불리는 메서드이다. 사용자가 다시 작성할까요? 하는 다이얼로그를 통해 이전에 작성했던 메모를 지우거나, 이전에 작성했던 메모를 유지한 상태로 작업을 이어나갈 수 있게 된다.
override fun onRestart() {
super.onRestart()
AlertDialog.Builder(this)
.setTitle("다시 작성할까요?")
.setMessage("작성한 내용이 모두 삭제됩니다.")
.setPositiveButton("다시 작성") { _, _ ->
binding.editText.setText("")
text = null
}
.setNegativeButton("취소") { _, _ ->
// 아무것도 안 함
}
.show()
}
👉🏻 전체코드
class MainActivity : AppCompatActivity() {
private lateinit var binding:ActivityMainBinding
private var text: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.button.setOnClickListener {
// 다음 화면으로 이동
val intent = Intent(this, SecondActivity::class.java)
intent.putExtra("text", text)
startActivity(intent)
}
}
override fun onResume() {
super.onResume()
if (!text.isNullOrEmpty()) {
binding.editText.setText(text)
}
}
override fun onPause() {
super.onPause()
text = binding.editText.text.toString()
}
override fun onRestart() {
super.onRestart()
AlertDialog.Builder(this)
.setTitle("다시 작성할까요?")
.setMessage("작성한 내용이 모두 삭제됩니다.")
.setPositiveButton("다시 작성") { _, _ ->
binding.editText.setText("")
text = null
}
.setNegativeButton("취소") { _, _ ->
// 아무것도 안 함
}
.show()
}
}
이제 생명주기에 대해 조금은 이해가 됐길 바란다
다음으로 넘어가보자😎
하나의 프로세스 내에서 실행되는 작은 작업의 단위이다. 이러한 작업들은 동시에 실행될 수 있다. 즉, 프로그램이 여러 작업을 처리하면서 다른 작업을 동시에 실행할 수 있다.
싱글 스레드(single-thread)는 하나의 실행 스레드(thread)만 가지는 것을 말한다. 즉, 하나의 프로세스(process)에서 단일 스레드만 실행된다.
싱글 스레드 애플리케이션은 한 번에 하나의 작업만 처리할 수 있다. 한 작업이 완료되기 전에 다른 작업을 수행할 수 없으며, 이러한 경우 애플리케이션이 느려지거나 멈출 수 있다.
멀티스레드(multi-thread)는 하나의 프로세스(process) 내에서 여러 개의 스레드(thread)가 동시에 실행되는 것을 말한다. 즉, 하나의 애플리케이션이 동시에 여러 작업을 수행할 수 있다.
멀티스레드 애플리케이션은 여러 개의 스레드가 동시에 실행되므로 시스템의 성능을 향상시킬 수 있다. 예를 들어, 웹 서버 애플리케이션에서 요청을 처리하는 스레드와 데이터베이스에서 데이터를 가져오는 스레드를 병렬로 실행할 수 있다.
즉, 한 화면에서 여러 기능을 동작시키고 싶을 때, 다른 쓰레드를 정의해서 메인 액티비티에 붙여주면, 각 동작을 병렬적으로 수행하기에 효과적으로 어플리케이션을 동작시킬 수 있다는 말이다😊
생명주기와 쓰레드의 개념을 이해하지 못하고 어플리케이션을 개발하려고 하다보니 당연히 한 액티비티에서 한가지의 생명주기만을 갖는 제한적인 어플리케이션이 만들어졌었다.
스레드 간 통신을 위한 메커니즘 중 하나이다. Handler는 메시지 큐를 사용하여 스레드 간 메시지를 전달하고 처리할 수 있다.
스레드의 루프를 관리하는 클래스이다. Looper는 해당 스레드의 메시지 큐를 루프에 묶어서 메시지 큐에서 메시지를 처리하고, 새로운 메시지가 도착할 때까지 계속해서 대기한다.
쓰레드와 핸들러를 타이머를 통해서 익혀보자.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// 핸들러 객체 생성
handler = object : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
if (msg.what == MESSAGE_UPDATE_TIMER) {
val time = msg.obj as String
// UI에 타이머 시간 업데이트
binding.text.text = time
}
}
}
// 타이머 스레드 생성 및 시작
timerThread = TimerThread(handler)
timerThread.start()
var isTimerRunning = true
// 시작 버튼 클릭 시 타이머 재개
binding.sbutton.setOnClickListener {
timerThread.resumeTimer()
}
// 일시 정지 버튼 클릭 시 타이머 일시 정지 또는 재개
binding.pbutton.setOnClickListener {
if (isTimerRunning) {
timerThread.pauseTimer() // 타이머 일시 정지
}
isTimerRunning = !isTimerRunning // 타이머 상태 토글
}
}
//앱이 끝날 때, 타이머 스레드도 같이 꺼주기
override fun onDestroy() {
super.onDestroy()
timerThread.stopTimer()
}
companion object {
private const val MESSAGE_UPDATE_TIMER = 1 // 타이머 업데이트 메시지 상수
private const val MESSAGE_TIMER_END = 2 // 타이머 종료 메시지 상수
}
private class TimerThread(private val handler: Handler) : Thread() {
private var isRunning = true // 타이머가 실행 중인지 여부를 나타내는 변수
private var isPaused = false // 타이머가 일시 정지되었는지 여부를 나타내는 변수
private var seconds = 30 // 타이머의 초 단위 시간
fun startTimer() { // 타이머를 시작하는 함수
isRunning = true
}
fun pauseTimer() { // 타이머를 일시 정지하는 함수
isPaused = true
}
fun resumeTimer() { // 일시 정지된 타이머를 다시 시작하는 함수
isPaused = false
}
fun stopTimer() { // 타이머를 중지하는 함수
isRunning = false
}
override fun run() {
while (isRunning && seconds > 0) { // 타이머가 실행 중이고 남은 시간이 0보다 클 때 반복
if (!isPaused) { // 타이머가 일시 정지되지 않았을 때
try {
sleep(1000) // 1초 대기
seconds-- // 남은 시간 감소
val time = formatTime(seconds) // 시간을 포맷팅하여 문자열로 변환
val message = handler.obtainMessage(MESSAGE_UPDATE_TIMER, time) // 타이머 업데이트 메시지 생성
handler.sendMessage(message) // 핸들러에 메시지 전송
} catch (e: InterruptedException) {
e.printStackTrace()
}
}
}
if (seconds == 0) { // 타이머가 종료되었을 때
val message = handler.obtainMessage(MESSAGE_TIMER_END) // 타이머 종료 메시지 생성
handler.sendMessage(message) // 핸들러에 메시지 전송
}
}
private fun formatTime(seconds: Int): String { // 시간을 포맷팅하는 함수
val minutes = seconds / 60 // 분 단위 계산
val remainingSeconds = seconds % 60 // 초 단위 계산
return String.format("%02d:%02d", minutes, remainingSeconds) // 포맷팅된 문자열 반환
}
이제 기본적인 어플리케이션 동작 방식을 이해했으니, 본격적으로 구성해보자😎
요가 자세가 맞으면 카운트 다운이 내려가는 기능을 구현하기 해보려고 했다. 처음엔 쓰레드와 생명주기를 이해하지 못해서 따로 카운트 다운 함수를 아래처럼 만들었다. 카운트 다운이 끝나면 다시 onCreate를 수행했기에 화면 깜빡임이 있을 수 밖에..!
생명주기를 이해하지 않고 따로 함수를 만드니까 뒤에 .start()까지 붙이고, 시간 낭비를 많이 했다.
fun countDown(time: String,asanaClass: AsanaClass) {
var conversionTime: Long = 0
// 1000 단위가 1초
// 60000 단위가 1분
// 60000 * 3600 = 1시간
var getHour = time.substring(0, 2)
var getMin = time.substring(2, 4)
var getSecond = time.substring(4, 6)
// "00"이 아니고, 첫번째 자리가 0 이면 제거
if (getHour.substring(0, 1) === "0") {
getHour = getHour.substring(1, 2)
}
if (getMin.substring(0, 1) === "0") {
getMin = getMin.substring(1, 2)
}
if (getSecond.substring(0, 1) === "0") {
getSecond = getSecond.substring(1, 2)
}
// 변환시간
conversionTime =
java.lang.Long.valueOf(getHour) * 1000 * 3600 + java.lang.Long.valueOf(getMin) * 60 * 1000 + java.lang.Long.valueOf(
getSecond
) * 1000
// 첫번쨰 인자 : 원하는 시간 (예를들어 30초면 30 x 1000(주기))
// 두번쨰 인자 : 주기( 1000 = 1초)
object : CountDownTimer(conversionTime, 1000) {
// 특정 시간마다 뷰 변경
override fun onTick(millisUntilFinished: Long) {
// 시간단위
var hour = (millisUntilFinished / (60 * 60 * 1000)).toString()
// 분단위
val getMin = millisUntilFinished - millisUntilFinished / (60 * 60 * 1000)
var min = (getMin / (60 * 1000)).toString() // 몫
// 초단위
var second = (getMin % (60 * 1000) / 1000).toString() // 나머지
// 밀리세컨드 단위
val millis = (getMin % (60 * 1000) % 1000).toString() // 몫
// 시간이 한자리면 0을 붙인다
if (hour.length == 1) {
hour = "0$hour"
}
// 분이 한자리면 0을 붙인다
if (min.length == 1) {
min = "0$min"
}
// 초가 한자리면 0을 붙인다
if (second.length == 1) {
second = "0$second"
}
binding.textView.setText("$hour:$min:$second")
}
// 제한시간 종료시
override fun onFinish() {
// 변경 후
binding.textView.setText("Done")
// TODO : 타이머가 모두 종료될때 어떤 이벤트를 진행할지
ttsSpeechManager.speakNextAsana(asanaClass)
onResume()
}
}.start()
}