지금까지 강의를 들으면서 가장 어려웠다. 구조가 많이 잡혀서, 이해하기 어려운 부분이 많았다. 내 블로그의 목적은, 포스팅하면서 이해하는 것이니 열심히 포스팅하면서 모르는거 찾아가면서 정리해야겠다.
# 결과물 미리보기
원래는 녹음시 내 음성의 크기에 따라서 위에 뜨는 ------모양이 바뀌어야 하는데, 이상하게 내 AVD는 마이크 입력이 잘 작동하지 않았다. 기능은 우리가 흔히 사용하는 녹음기와 똑같지만 별도로 녹음된 음성을 저장하여 관리하지는 않는다.
# 구현 순서
사용된 액티비티와 각각의 역할에 대해서 정리하자면,
- MainActivity
- 모든 기능들에 대해서 종합적으로 돌아가는 플로우를 구현
- SoundVisualizerView
- 음성이 볼륨에따라서 시각화 되는 View를 만들었음, canvas와 onDraw()활용
- RecordButton
- RecordButton이 상태에따라서 모양이 바꾸는 버튼(View)을 새로 만들었음
- CountUpView
- 녹음시간 및 재생시간을 나타내주는 TextView를 새로 정의하여 사용함
이번단원에서는 위와 같이 View들을 새로 정의하여 많이 사용했다. 저번에는 layout을 inflater를 사용하여 커스텀하게 사용하는방법을 배웠었는데, 이번에는 View자체를 새로 만들어 사용했다.
# 알게 된 것
- Enum클래스를 사용하여 State 상수관리
- set(value){...}
- run{}
- companion object
- MediaRecorder
- MediaPlayer
- View 만들기
- SoundVisualizerView
- RecordButton
- CountUpView
## Enum클래스를 사용하여 State 상수관리
이번 프로젝트에서는, 먼저 main에서 현재 state기록을 가지고 있고, 버튼을 누를때마다, 녹화시작, 녹화중지, 녹음재생, 녹음멈추기 등 한 버튼에서 여러개의 상태를 관리했어야 했다. 그렇기 때문에 State에 대한 정의가 필요했고 이 State를 편리하게 사용 할 수 있도록 하기 위해서 Enum이라는 클래스를 정의하여 사용했다.
enum class State {
BEFROE_RECORDING,
ON_RECORDING,
AFTER_RECORDING,
ON_PLAYING
}
이렇게 State를 정리 해놓은후, 버튼을 클릭했을때 상황별로 이벤트를 분기시킬 수 있었다.
recordButton.setOnClickListener {
when (state) {
State.BEFROE_RECORDING -> {
startRecorder()
}
State.ON_RECORDING -> {
stopRecorder()
}
State.AFTER_RECORDING -> {
startPlayer()
}
State.ON_PLAYING -> {
stopPlayer()
}
}
}
학교에서 프로젝트하면서 안드로이드와 코틀린자체를 처음 접했었는데 그때는 when문이라는 것의 존재 자체를 몰랐는데.. 어떻게 코드를 짰나 싶을 정도로 when문이 많이 쓰인다는 것을 느끼고 있다.
## set()
바로 위 코드에서 각 분기마다 존재하는 start....() , stop....() 함수를 실행하면 정의된 이벤트를 실행한 후 메인의 state를 바꿔주게 된다. 그리고 따로 버튼을 모양을 바꿔주는 어떤 작업도 하지 않았다. 그렇기 때문에 어디선가는 이 버튼을 바꿔줘야 했다.
private fun startRecorder() {
// 버튼 클릭시 정의된 이벤트 수행 ...
// 버튼 클릭시 정의된 이벤트 수행 ...
// 현재 state를 바꿔줌
state = State.ON_RECORDING
}
밑에서 정리하겠지만, 이 RecordButton에 대해서는 ImageButton을 상속받았기 때문에 기존의 버튼이 가지는 작업은 그대로 수행한다. 대신 state별로 버튼 모양(이미지)를 바꿔주는 함수를 별도 구현 해 놨다.
MainActivity에서느 state를 정의한 곳 아래에 이렇게 set(value){ ... } 라는 것을 정의 했다.
private var state = State.BEFROE_RECORDING
set(value) {
field = value
resetButton.isEnabled = (value == State.AFTER_RECORDING) || (value == State.ON_PLAYING)
recordButton.updateIconWithState(value)
}
이게 어떤 것인지 절대로 이해 할 수 없다. 왜냐하면 코틀린과 자바의 차이점에서 오게 되는 것이기 때문이다.
코틀린에서는 getter와 setter를 알아서 지정해준다.
class Person() {
val name = "testName"
var age = 24
var isStudent = false
}
// 우리가 코틀린에서 사용하는 것
fun main(args: Array<String>) {
val person = Person()
println(person.name)
println(person.age)
person.age = 20
if (person.isStudent) {
}
}
// 실제로 사용되는 코드
// 자동으로 get와 set이 생겨서 사용 됨
fun main(args: Array<String>) {
val person = Person()
println(person.getName())
println(person.getAge())
person.setAge(20)
if (person.isStudent()) {
}
}
아래 코드를 다시 봐보면서 생각해보자. 아래 코드는 지금껏 보았단 setState의 기능을 다시 정의해준 것이다.
private var state = State.BEFROE_RECORDING
set(value) {
field = value
resetButton.isEnabled = (value == State.AFTER_RECORDING) || (value == State.ON_PLAYING)
recordButton.updateIconWithState(value)
}
그렇다면 저 state는 MainActivity클래스의 변수이고, 어디선가 state를 변경하게 된다면 사실은 setState()이 호출되기 때문에 위에서 정의한 것들이 실행된다. 그러면서 recordButton.updateIconWithState()가 알아서 실행되기 때문에 state만 변경해주더라도 자동으로 이미지가 바뀌는 것 같은 느낌을 받을 수 있는 것이다.
## .run{}
지금껏 .apply{..} .let{...}을 사용했었다.
.apply{...}은 현재 객체를 다시 반환하여 it.start()와 같은 것 또는 자신의 프로퍼티를 변경할 수 있었다.
.let{...}은 null이 아닐경우 해당 구문을 실행시켜줬다.
run은 마지막 구문을 return 시킨다. 맨 밑에 참고시킨 링크를 따라가보면 수행해야할 상황별로 잘 정리해주셨다.
recorder?.run {
stop()
release()
Log.d(tag,"stop Recorder!!")
}
## companion object
자바에서 static과 같다. 클래스의 객체를 생성하지 않고 사용할 수 있으며, 전 객체에서 공유된다.
companion object{
private const val LINE_WIDTH = 10F
private const val LINE_SPACE = 15F
private const val MAX_AMPLITUDE = Short.MAX_VALUE.toFloat()
private const val ACTION_INTERVAL = 20L
}
## MediaRecorder
음성 녹음을 하기 위해서 사용한다. 재생을 위해서는 다은 단락에서와 같이 MediaPlayer를 정의해줘야한다.
// 안쓸때는 메모리에서 해제해주기 위해서 null이 가능하게 해준다
private var recorder: MediaRecorder? = null
// start
private fun startRecorder() {
recorder = MediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP)
setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB)
setOutputFile(recordingFilePath) // 저장위치
prepare()
Log.d(tag,"start Recorder!!")
}
recorder?.start()
state = State.ON_RECORDING
}
// stop
private fun stopRecorder() {
recorder?.run {
Log.d(tag,"stop Recorder!!")
stop()
release()
}
recorder = null
state = State.AFTER_RECORDING
}
아래 사진과 같이 state순서에 지켜서 함수를 호출하여 초기화 시켜줘야한다.
## MediaPlayer
레코더와 똑같이 사용된다. 호출되는 함수의 이름만 조금 다르다
private var player: MediaPlayer? = null
// start
private fun startPlayer() {
player = MediaPlayer().apply {
setDataSource(recordingFilePath)
prepare()
Log.d(tag,"start Player!!")
}
player?.setOnCompletionListener {
stopPlayer()
state = State.AFTER_RECORDING
}
player?.start()
state = State.ON_PLAYING
}
// stop
private fun stopPlayer() {
player?.release()
Log.d(tag,"stop Player!!")
player = null
state = State.BEFROE_RECORDING
}
# View 만들기
안드로이드에서 지원하는 위젯(view)가 아니거나, view기능을 내 뜻에 맞게 수정하고 싶을때는 View을 만들어서 사용 할 수 있으며, 기본 위젯들을 상속 받아 사용 가능하다. 예컨대 특별한 버튼을 만들고 싶은데 버튼을 처음부터 다 만드는 것이 아닌, Button을 상속받아 기본적으로 클릭이벤트와 같은 것들은 구현 된 상태에서 구현 시킬 수 있다.
## RecordButton
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageButton
import com.example.part2.recorder.State.*
class RecordButton(
context: Context,
attrs: AttributeSet
) : AppCompatImageButton(context,attrs) {
fun updateIconWithState(state : State) {
when(state) {
BEFROE_RECORDING -> {
setImageResource(R.drawable.ic_record)
}
ON_RECORDING -> {
setImageResource(R.drawable.ic_baseline_stop_24)
}
AFTER_RECORDING -> {
setImageResource(R.drawable.ic_baseline_play_arrow_24)
}
ON_PLAYING -> {
setImageResource(R.drawable.ic_baseline_stop_24)
}
}
}
}
상태에 따라서 배경이미지가 자동으로 바뀐다. 내가 상태를 바꿔주고 싶을때마다 RecordButton.updateIconWithState("상태")값만 전달하면 자동으로 바뀌기 때문에 매우 간편하고, 추상화 되어 좋다.
매개변수로는 Context와 AttributeSet을 필수적으로 받아줘야하며, 상속시에는 원하는 View를 상속 받으면 된다. 또한 AppCompat이 들어간 View를 상속 받아 사용해야한다.
## SoundVisualizerView
해당 부부도 똑같이 view를 만들어 사용했다. 특히 canvas와 onDraw의 개념을 사용해서 View가 어떤 모양을 가질지를 구현 시켰다.
onDraw()에서는 Paint()객체를 넣어줘야하는데 이 Paint는 View에 그릴 요소의 색이나 모양 두께등 디자인 적요소를 담당한다.
onDraw()의 drawLine()을 이용하여 그 요소가 등장할 곳 끝날곳, 지정한 Paint를 넣어줄 수 있다.
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.os.Build
import android.util.AttributeSet
import android.view.View
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import kotlin.random.Random
class SoundVisualizerView(
context: Context,
attrs: AttributeSet,
) : View(context,attrs){
@RequiresApi(Build.VERSION_CODES.M)
private val amplitudePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = context.getColor(R.color.purple_500)
strokeWidth = LINE_WIDTH //외곽선두께
strokeCap = Paint.Cap.ROUND //끝 부분처리
// 높이는 소리에 맞게 동적으로 만들어져야함
}
private var drawingWidth :Int = 0
private var drawingHeight : Int = 0
private var drawingAmplitudes : List<Int> = emptyList()
private var isRePlaying : Boolean = false
private var replayingPosition: Int = 0
//Runnable 만들기
private val visualizeRepeatAction : Runnable = object : Runnable {
override fun run() {
if(!isRePlaying) {
val currentAmpltude = onRequestCurrentAmplitude?.invoke() ?:0// 최대 값을 가져옴
drawingAmplitudes = listOf(currentAmpltude) + drawingAmplitudes
} else {
replayingPosition++
}
invalidate()
handler?.postDelayed(this,ACTION_INTERVAL)
}
}
// 호출할 예정임
var onRequestCurrentAmplitude: (()->Int)?= null
// view의 size가 변할때 호출 됨
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
drawingWidth = w
drawingHeight = h
}
@RequiresApi(Build.VERSION_CODES.M)
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
canvas ?: return
val centerY = drawingHeight / 2f // 높이의 절반 ( 가운데 )
var offsetX = drawingWidth.toFloat()
drawingAmplitudes
.let { amplitudes ->
if(isRePlaying) {
amplitudes.takeLast(replayingPosition)
}else {
amplitudes
}
}
.forEach { amplitude ->
val lineLength = amplitude / MAX_AMPLITUDE * drawingHeight * 0.8F
offsetX -= LINE_SPACE
if(offsetX < 0 ) return@forEach
canvas.drawLine(offsetX,centerY - (lineLength/2F),offsetX,centerY + (lineLength/2F),amplitudePaint)
}
}
fun startVisualizing(isReplaying : Boolean) {
this.isRePlaying = isReplaying
handler?.post(visualizeRepeatAction)
}
fun stopVisualizing() {
replayingPosition = 0
handler?.removeCallbacks(visualizeRepeatAction)
}
fun clearVisualiztion() {
drawingAmplitudes = emptyList()
isRePlaying = false
stopVisualizing()
invalidate()
}
companion object{
private const val LINE_WIDTH = 10F
private const val LINE_SPACE = 15F
private const val MAX_AMPLITUDE = Short.MAX_VALUE.toFloat()
private const val ACTION_INTERVAL = 20L
}
}
## CountUpView
package com.example.part2.recorder
import android.content.Context
import android.os.SystemClock
import android.provider.Settings
import android.util.AttributeSet
import android.widget.TextView
import androidx.appcompat.widget.AppCompatTextView
class CountUpView(
context: Context,
attrs: AttributeSet,
) : AppCompatTextView(context, attrs) {
private var startTimestamp: Long = 0L
private val countUpAction: Runnable = object : Runnable {
override fun run() {
val currentTimeStamp = SystemClock.elapsedRealtime()
val countTimeSeconds = ((currentTimeStamp - startTimestamp) / 1000L).toInt()
updateCountTime(countTimeSeconds)
handler?.postDelayed(this, 1000L)
}
}
fun startCountUp() {
startTimestamp = SystemClock.elapsedRealtime()
handler?.post(countUpAction)
}
fun stopCountUp() {
handler?.removeCallbacks(countUpAction)
}
fun clearCountTime() {
updateCountTime(0)
stopCountUp()
}
private fun updateCountTime(countTimeSeconds: Int) {
val minutes = countTimeSeconds / 60
val second = countTimeSeconds % 60
text = "%02d:%02d".format(minutes, second)
}
}
# 느낀 점
View를 생성한다는 것 까지는 잘 이해가 됐는데, 이 View안에서 Runnable을 사용해서 쓰레드 개념이 나오니까, 그 쓰레드가 어떤 방식으로 돌아가는지가 이해가 되지 않았다. 특히 SoundVisualizeView를 만들때 Runnable이 어떤 handler에 전달되는지 잘 모르 겠고, UI요소를 변경하려면 UIThread나 메인 쓰레드에서 작업해야하는데 여기서 onDraw()도 분명 어떤 UI를 조작하는 것이라고 생가각하는데 그런 요소가 없었다..
그래도 나름 코딩 스타일이란 앱을 어떤식으로 구조화 해야하는지에 대해서 자연스럽게 익혀가고 있는 느낌이 든다. 이렇게 구조화 시키는 패턴이랑 쓰레드(Runnable)을 다루는 게 중요 한 것 같다. 할때마다 느끼지만 너무나도 완벽한 객체지향이라.. 그러한 개념들이 완벽히 체득이 되어야만 좋은 코드를 작성 할 수 있을 것이라는 생각이 든다.
## 궁금한 것
- handler가 어떤 handler인지 모르겠다. handler를 정의하지 않으면 어떤 handler가 동작하는 것일까..?
- cnavas와 onDraw가 신기한데, 정확히 모르겠다. 이걸로 뭐 하나 만들어보면서 알아가면 좋을거 같다.
set(value)에 대한 설명
미디어 레코더