Request runtime permissions
CustomView
MediaRecorder
//////////
--> 오디오를 시각화하는 CustomView를 그릴 때 해당 자료를 바탕으로 각각 Line을 그려서 처리할 것임
아래와 같은 값을 정해서 CustomView에 Custom Drawing을 이용하여 그릴 것임
좌표설정에 대해서는 아래와 같이 centerY값을 잡고, 우측에서 좌측으로 X좌표가 이동하도록 하여 움직이는 것처럼 만들 것임
setter 정리해 놓은 글 : https://velog.io/@odesay97/Kotlin-vs-Java-Kotlin%EC%9D%98-%EC%9E%A5%EC%A0%90
--> Java에서의 static과 비슷한 개념이다.
Class 내부에 companion object를 선언해놓으면, 해당 객체는 class의 모든 인스턴스에게 접근권한을 준다.
companion object의 이름은 생략될 수 있으며, 그럴 때엔 Companion이란 식별자를 사용한다.
이 때 중요한 것은 이렇게 정의된 객체는 싱글톤으로 class내에서 유일하다.
또한 Class 내에서 companion object는 한개만 정의될 수 있다.
......
class SoundVisualizerView(
context: Context,
attrs: AttributeSet?=null
) : View(context,attrs){
......
companion object{
private const val AMPLITUDE_SIZE_RATE = 0.8F // amplitude의 크기를 얼마로 나타낼 것인지 비율을 조정하기 위해서 임의로 지정
private const val LINE_WIDTH = 10F
private const val LINE_SPACE = 15F
private const val MAX_AMPLITUDE = Short.MAX_VALUE.toFloat() // 32767 --> 진폭 최대값 지정
private const val ACTION_INTERVAL = 20L
}
}
--> companion object를 통해 class 내부에 이름을 생략한 객체를 만들어준 모습이다.
--> 해당 객체를 통해 전역 변수들을 선언해 주었다.
이에 대한 자세한 내용은 다른 앱 프로젝트에서 이미 다뤘음
제한된 데이터, 제한된 시스템, 또는 다른 앱에 영향을 줄 수 있는 권한을
runttime permissions 또는 dangerous permissions이라고 함
( 대부분 카메라, 마이크 등과 같이 사용자의 사생활에 영향을 줄 수 있는 것들에 대해 사용함 )
이번 앱에서는 마이크에 대한 권한을 요청할 것임
manifest에서 해당 코드로 정의
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
package com.example.aop_part2_chapter7
import android.content.pm.PackageManager
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import java.util.jar.Manifest
class MainActivity : AppCompatActivity() {
private val recordButton: RecordButton by lazy {
findViewById(R.id.recordButton)
}
private val requiredPermissions = arrayOf(android.Manifest.permission.RECORD_AUDIO)
private var state = State.BEFORE_RECORDING
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
requestAudioPermission()
initViews()
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
val audioRecordPermissionGranted = requestCode == REQUEST_RECORD_AUDIO_PERMISSION &&
grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED
if (!audioRecordPermissionGranted){
finish()
}
}
private fun requestAudioPermission(){
requestPermissions(requiredPermissions, REQUEST_RECORD_AUDIO_PERMISSION)
}
private fun initViews(){
recordButton.updateIconWithState(state)
}
// companion object가 뭔지 잘 모르겠음
companion object {
private const val REQUEST_RECORD_AUDIO_PERMISSION = 201
}
}
A 부분에서 정의한 메소드를 통해 permission을 요청함
B-1 부분에서 어떤 permission에 대한 것인가 ArrayList형태로 정의
-> A의 첫번째 파라미터
B-2 부분에서 어떤 requestCode로 할 것인가를 한곳에 모아놓음 ( 나중에 여러 requestCode가 존재할 때 이런식으로 해놔야 보기 편함 )
-> A의 두번째 파라미터
C 부분에서 permission 요청에 대해 사용자가 허용했는지 여부를 확인
허용했다면 그냥 넘어감
허용하지 않았다면 앱을 종료
커스텀뷰 만들기 공식문서 : https://developer.android.com/training/custom-views/create-view?hl=ko
class PieChart(context: Context, attrs: AttributeSet) : View(context, attrs)
먼저 해당 부분을 보면 CustomView를 만들기 위해서는
기본적으로 해당 클래스가 Context와 AttributeSet을 파라미터로 받아야하는 것을 알 수 있다.
또한 해당 CustomView를 통해 만들고 싶은 View를 상속받아
해당 Context와 AttributeSet을 파라미터로 전달해야 한다는 것도 알 수 있다.
예를 들어,
위와 같이 일반 View를 상속받고 싶다면
View(context, attrs)를 상속받으면 되고,
TextView를 CustomView로 만들고 싶다면
AppCompatTextView(context, attrs)로 상속받으면 되고,
Button을 CustomView로 만들고 싶다면
AppCompatButton(context,attrs)로 상속받으면 되는 등 이런식으로 하면된다.
또한 CustomView의 디자인을 위해서 Custom Drawing이라는 기능을 사용할 것이다.
Custom Drawing에 대한 공식문서 : https://developer.android.com/training/custom-views/custom-drawing
onDraw()메소드는 파라미터로 Canvas 객체를 받으며,
해당 Canvas 클래스는 텍스트, 선, 비트맵 등의 메소드를 정의하고, 이를 이용하여 디자인을 한다.
onDraw()에선 이런 메소드들을 사용하여 UI를 디자인 해 줄 수 있다.
하지만 위의 텍스트, 선, 비트맵 등의 그리기 메소드를 호출하기 위해선 Paint객체를 만들 필요가 있다.
onDraw()는 화면이 그려질 때 처음 호출된 이후ㅡ, invalidate() 메소드를 통해서만 호출할 수 있다.
[onDraw()를 호출하기 위한 메소드]
invalidate()
해당 메소드를 사용해서 onDraw()를 호출할 수 있다.
--> 따라서 화면이 순차적으로 변하기 위해 onDraw()를 호출해야한다면,
invalidate()를 이용해야 한다.
--> 따라서 무엇을 그리기 전에 하나이상의 Paint객체가 필요함
onDraw()메소드 내부에서 Paint객체를 생성해서는 안됨
왜??
onDraw()는 아주 빈번하게 호출되는 함수이므로,
비용이 많이 드는 객체생성이라는 부분을 onDraw()안에 정의하는 것은 바람직하지 못함
--> 유의미한 성능저하를 야기한다.
따라서 onDraw()를 생성하기 전에 미리 Paint객체를 생성하여 적용하는 것이 좋다.
( 이 부분에 대한 더 자세한 내용은 다음에 Custom View를 사용할 때 정리할 것임 )
[ onSizeChanged() 메소드 ]
View가 처음 그려지거나, View의 사이즈가 변경되면 실행되는 메소드
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
// 처음 그려지고 나서의 w와 h는 해당 뷰의 width크기와 height크기이다.
drawingWidth = w
drawingHeight = h
}
--> 위와 같이 onSizeChanged()를 재정의하여 해당 View가 사용되었을 당시에 크기를 가져올 수 있다.
예를들어, 해당 뷰가 액티비티에 사용될 당시에 w=500, h = 800이라면,
onSizeChaged의 w와 h 파라미터의 인자로 그 값이 전달되므로
위와같이 해당 값에 따라 View가 전개되도록 코딩하면된다.
ㅡㅡ뷰의 크기를 더 세밀하게 처리하려면 onMeasure() 메소드를 오버라이드해서 사용ㅡㅡ
객체 생성( 위에 1번 )
및 측정 코드를 정의( 위에 2번 )
하고나면 onDraw()를 구현할 수 있습니다.
모든 뷰는 onDraw()를 다르게 구현하지만, 대부분의 뷰에서 공유하는 몇 가지 일반적인 작업이 있습니다.
drawText()를 사용하여 텍스트를 그립니다.
--> setTypeface()를 호출하여 글꼴을 지정하고
--> setColor()를 호출하여 텍스트 색상을 지정합니다.
drawRect(), drawOval() 및 drawArc()를 사용하여 기본 도형을 그립니다.
--> setStyle()을 호출하여 도형을 채우는지, 윤곽선만 표시하는지 또는 둘 다인지 설정합니다.
Path 클래스를 사용하여 더 복잡한 도형을 그립니다.
Path 객체에 선과 곡선을 추가하여 도형을 정의한 다음,
drawPath()를 사용하여 도형을 그립니다.
--> 기본 도형과 마찬가지로, 경로는 setStyle()에 따라 윤곽선만 표시하거나 색상을 채우거나 또는 둘 다 할 수 있습니다.
LinearGradient 객체를 만들어서 그라데이션 채우기를 정의합니다.
--> 색을 채운 도형에 LinearGradient를 사용하려면 setShader()를 호출합니다.
drawBitmap()을 사용하여 비트맵을 그립니다.
예를 들어, PieChart를 그린 코드는 다음과 같습니다. 이 코드에서는 텍스트, 선 및 도형을 혼합해서 사용합니다.
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.apply {
// Draw the shadow
drawOval(shadowBounds, shadowPaint)
// Draw the label text
drawText(data[mCurrentItem].mLabel, textX, textY, textPaint)
// Draw the pie slices
data.forEach {
piePaint.shader = it.mShader
drawArc(bounds,
360 - it.endAngle,
it.endAngle - it.startAngle,
true, piePaint)
}
// Draw the pointer
drawLine(textX, pointerY, pointerX, pointerY, textPaint)
drawCircle(pointerX, pointerY, pointerSize, mTextPaint)
}
}
......
private val amplitudePaint = Paint(Paint.ANTI_ALIAS_FLAG)
.apply {
color = context.getColor(R.color.purple_500)
strokeWidth = LINE_WIDTH // 이번 경우 width의 크기는 고정되어 있으므로 Paint에서 지정해줌
strokeCap = Paint.Cap.ROUND
}
......
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
canvas ?: return
val centerY = drawingHeight / 2f
var offsetX = drawingWidth.toFloat()
// 현재 X 지점을 의미 --> 오른쪽에서부터 왼쪽으로 Line이 진행되므로
// 초기값을 화면의 오른쪽 끝값인 drawingWidth로 설정 ( 시작 위치 )
drawingAmplitudes.forEach{ amplitude ->
val lineLength = amplitude / MAX_AMPLITUDE * drawingHeight * AMPLITUDE_SIZE_RATE
offsetX -= LINE_SPACE
if (offsetX < 0) return@forEach
// 녹음되는 동안 그려진 Line의 offsetX(x좌표값)에 LINE_SPACE만큼의 값을 빼서 옆으로 이동
// 이때 offsetX 가 음수가 되는 지점이 바로 해당 Line이 앱의 화면을 벗어난 시점임
// 따라서 이때부터는 그려주지 않고 그냥 넘어가도록 설정한 것
// Y값에 대해 화면 중앙을 기준으로 선의 길이만큼 위아래로 해서 직선을 그림
canvas.drawLine(
offsetX,
centerY - lineLength/2,
offsetX,
centerY + lineLength/2,
amplitudePaint
)
}
}
Paint 객체를 onDraw()의 외부에서 따로 정의해주었음
onDraw()를 오버라이드 해서 사용 -> 내부에서 canvas를 이용하여 그림을 그려줌
onDraw()를 반복적으로 불러내야하는 경우도 있는 데, onDraw()를 호출하는 메소드는 invalidate()임
--> 그냥 invalidate()를 쳐주면 onDraw()가 실행됨 --> onDraw()내부에 canvas도 같이 실행되므로 화면이 재정의 됨
CustomView에서는 정형화된 View가 아닌,
내가 원하는 형태의 View를 만들 수 있다는 장점이 있다.
그리고 그런 장점들과 함께
상황에 따라서는 CustomView가 기능하기 위해서
CustomView를 사용하는 액티비티와 CustomView가 서로 연동할 필요성이 있다.
즉, 아래와 같은 부분이다.
외부( 이 CustomView를 사용하는 액티비티 )에 있는 값을 내부( CustomView )에 전달하기 위한 방법
외부( 이 CustomView를 사용하는 액티비티 )에서 내부( CustomView )를 제어하기 위한 방법
방법에 대해 설명하자면
1. 먼저 CustomView 내부에 빈 메소드를 담을 변수를 만듬
( 인터페이스를 구성하여 하기도 함 )
// Int를 반환하는 메소드에 대한 변수이며, null 가능
var onRequestCurrentAmplitude: (()->Int)? = null
외부(이 CustomView를 사용하는 액티비티)에 있는 값을 내부(CustomView)에 전달하기 위한 메소드
따라서 이 메소드의 정의는 외부(이 CustomView를 사용하는 액티비티)에서 이루어짐
주의할 점은 private으로 설정해선 안된다는 것임 --> 외부에서 이 변수에 접근 가능해야하므로
2. 이후 이 CustomView를 사용하는 액티비티에서 해당 메소드를 정의하여 값을 반환하도록 만듬
......
// activity_main.xml에서 사용한 CustomView에 대해 그 CustomView를 kt파일에 가져옴
private val soundVisualizerView : SoundVisualizerView by lazy {
findViewById(R.id.soundVisualizerView)
}
......
// 위에 CustomView에 있는 해당 메소드를 정의
soundVisualizerView.onRequestCurrentAmplitude = {
recorder?.maxAmplitude ?: 0
}
......
3. CustomView내부에서 해당 메소드에 대해 invoke()를 이용하여 실행하도록 코딩
val currentAmplitude = onRequestCurrentAmplitude?.invoke() ?: 0
CustomView 내부에서 해당 메소드는 1번에서 정의했듯이 null이 할당되어 있다.
그래서 다른 메소드를 사용하듯이 그냥 실행하면 NullSafe가 발생한다.
여기서 invoke()메소드를 사용할 수 있는데, invoke()메소드는 앞의 메소드를 실행시킨다.
그래서 위와 같이 해당 메소드가 정의되어있다는 것을 전제로 코딩을 하며,
해당 메소드의 호출은 invoke() 메소드를 통해 nullSafe를 지키면서 하면 된다.
)))))) 즉, 이 내용을 요약해보면
CustomView 내부에 데이터를 받기 위한 빈 메소드 혹은 인터페이스를 만들고
CustinView를 사용하는 액티비티에서 1번에서 만든 메소드를 재정의 혹은 정의하고
CustomView 내부에서 1번에 만든 메소드가 정의되었다는 것을 전제로 하여 코딩을 하면 된다.
( invoke()를 사용하여 nullSafe 준수 )
1. CustomView 내부에 private으로 설정하지 않은 메소드를 정의
예시
fun startVisualizing(isReplaying: Boolean){
this.isReplaying = isReplaying
handler?.post(visualizeRepeatAction)
}
2. CustomView를 사용하는 액티비티에서 해당 메소드를 사용하여 CustomView 제어
......
private val soundVisualizerView : SoundVisualizerView by lazy {
findViewById(R.id.soundVisualizerView)
}
......
soundVisualizerView.startVisualizing(false)
......
이 앱에서 RecodeButton.kt 라는 ImageButton에 대한 CustomView에서 init{}을 사용하고 있다.
// View에서 init을 통해 해당 View의 속성에 접근할 수 있음
init {
setBackgroundResource(R.drawable.shape_oval_button)
}
init{} 의 내부에서 현재 CustomView의 속성에 접근할 수 있다.
--> 위 코드에서는 setBackgroundResource에 접근했다.
MediaRecorder 공식 문서 : https://developer.android.com/reference/android/media/MediaRecorder
MediaRecorder가 녹음을 하기 위해선 Initial 상태에서 시작하여 Initialized 상태, DataSourceConfigured 상태, Prepared 상태를 거쳐야만 Recording 상태 들어가 녹음을 할 수 있다.
여기서 중요한 것은 Recording상태에서 reset()이나 stop() 등을 통해 녹음을 종료 할 경우, Initial상태로 돌아가기 때문에 다시 녹음을 시작하기 위해서는 위의 과정을 다시 거쳐 재설정하여 Recording상태로 돌입해야한다는 점이다.
코덱 : Encoder와 Decoder를 합친 말이다.
--> 동영상이나 오디오의 경우 소스가 무겁다.
그래서 파일을 읽고 쓰려고 할 때 이런 작업을 좀 더 가볍게 하기 위해서 Encoder를 사용하여 압축시켜서 다루게 된다.
이렇게 압축된 데이터는 포멧으로 정의된 컨테이너에 정리되어 들어가며,
이후 Decoder를 통해 해당 포멧에서 데이터를 가져와 Decoding하여 사용하게 된다.
포멧 : Encoder를 통해 압축된 데이터들을 정리하여 넣는 컨테이너를 의미한다.
--> Decoder가 이후에 해당 포멧에서 데이터를 꺼내서 Decoding하여 넘겨준다.
MediaRecorder recorder = new MediaRecorder();
recorder.setAudioSource(MediaRecorder.AudioSource.MIC);
recorder.setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP);
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB);
recorder.setOutputFile(PATH_NAME);
recorder.prepare();
recorder.start(); // Recording is now started
...
recorder.stop();
recorder.reset(); // You can reuse the object by going back to setAudioSource() step
recorder.release(); // Now the object cannot be reused
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB);
-> 마이크로 들어온 데이터를 AMR_NB라는 방식으로 Encoding( 압축 )할 것임
recorder.setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP);
-> THREE_GPP라는 포멧을 사용하여 Encoding된 데이터를 보관한다.
코덱과 포멧 사이에는 호환성이 존재한다.
따라서 해당 Encoder가 해당 format과 호환이 되는지 확인한 후에 사용해줘야 한다.
이렇게 녹음된 오디오 데이터는 MediaPlayer를 통해 재생해 볼 수 있다.
--> 위의 표와 같이 Idle -> Initalized -> Prepared를 거쳐서 Started(실행)을 한다.
1. MediaPlayer 변수 정의
private var player:MediaPlayer? = null
2. MediaPlayer를 생성
private var player:MediaPlayer? = null
......
player = MediaPlayer()
.apply {
setDataSource(recordingFilePath)
prepare()
}
여기서 MediaPlayer 대해 여러가지 것들을 설정해 줄 수 있음
--> setDataSource("절대주소")메소드를 통해 MediaPlayer로 실행할 Media를 setting해줬음
(오디오 뿐만아니라 동영상도 가능하지만 그 경우 더 복잡한 과정이 필요)
설정이 완료되었으면 prepare() 메소드를 실행시켜 MediaPlayer를 반환해줌
MediaPlayer는 prepare()와 prepareAsync()의 두 종류의 준비가 있는데,
prepare()의 경우 데이터를 모두 가져온 다음에 실행
--> 따라서 데이터가 크면 다 가져올 때까지 멈춰있는 단점
--> 여기서는 간단한 녹음을 기준으로 하기 때문에 prepare()만으로 충분
prepareAsync()의 경우 데이터를 가져온 대로 바로바로 실행 --> 보통 스트리밍과 같은 가져올 데이터가 큰 경우에 사용
3. 메소드들을 이용하여 MediaPlayer 제어
private var player:MediaPlayer? = null
......
player?.start()
// 시작
player?.stop()
// 정지 --> 위의 흐름도 대로 stop()시엔 prepare()이후에 다시 start()할 수 있음
player?.release()
// MediaPlayer를 메모리에서 날려줌
위의 흐름도 대로 stop()시엔 prepare()이후에 다시 start()할 수 있음
MediaPlayer의 경우 stop()을 한 다음에 release()를 해줄 필요가 없다.
--> 왜냐하면 위의 흐름도를 보면 알겠지만, MediaRecorder의 흐름과는 다르게
어디서든지 release()를 하면 End로 가버리기 때문이다.
4. 그 외에 MediaPlayer와 관련된 메소드
setOnCompletionListener --> MediaPlayer의 내용에 대한 재생이 완료되면 실행되는 메소드
private var player:MediaPlayer? = null
......
player?.setOnCompletionListener {
stopPlaying()
}
MediaPlayer 예시
private var player:MediaPlayer? = null
......
private fun startPlaying(){
player = MediaPlayer()
.apply {
setDataSource(recordingFilePath)
prepare()
}
// player가 재생을 완료했을 때 실행되는 리스너
player?.setOnCompletionListener {
stopPlaying()
}
player?.start()
}
private fun stopPlaying(){
player?.release()
player = null
soundVisualizerView.stopVisualizing()
recordTimeTextView.stopCountUp()
state = State.AFTER_RECORDING
}
매년 새로운 버전의 안드로이드가 출시되는 가운데,
필연적으로 이전 버전에 대한 호환성 지원이 필요해짐
그래서 AppCompat으로 기존의 기능들을 Rapping해서
이전 버전에서도 새로 출시한 대부분의 기능들에 정상적으로 동작하게 만들어줌
이런 것들을 지원하는 라이브러리가 바로 AppCompat 라이브러리임
AppCompat에 맵핑할 수 있으면 자동으로 연결해주는 기능이 프로젝트에 설정되어있기 때문에
굳이 명시적으로 사용할 필요 없다. ( 기존의 View사용하듯이 사용하면 됨 )
그런 기능이 없기 떄문에 AppCompat을 명시해서 사용해야 한다.
앱별 저장공간에 대한 공식문서 : https://developer.android.com/training/data-storage/app-specific?hl=ko
앱에서 접근 할 수 있는 Storage는 한정되어 있음
이 중 internal 같은 경우 내부를 사용하므로 공간이 매우 한정적
-> 따라서 이번과 같이 파일의 용량이 얼마나 될지 모르는 상황에서는 적합하지 않음
이런 경우 외부 디렉토리를 사용하는 것으로 저장할 공간의 용량을 확보할 것이다.
또한
이번 앱에는 녹음한 데이터를 장기적으로 저장하지 않을 것이므로,
캐시 디렉토리에 임시 저장해놓을 것이다.
캐시 디렉토리에 저장한다는 의미는 앱이 지워진다거나 안드로이드의 용량이 부족하여 지워야할 필요성이 있을때 쉽게 지울 수 있도록 할 것이라는 의미이다.
externalCacheDir?.absolutePath
// 이렇게 하면 외부 캐시 디렉토리의 주소를 반환
[코드상의 예시]
private val recordingFilePath: String by lazy {
"${externalCacheDir?.absolutePath}/recording.3gp"
}
외부 캐시 저장소에 대한 절대주소를 가져옴
--> 뒤부분에 파일명에 대해 확장자가 .3gp인 이유는 녹음기의 포멧이 3gp이기 때문이다.
enum의 의미는 Enumerated Type으로 '서로 연관된 상수 값들의 집합'이라는 뜻이다.
enum class 내부에 정의된 것들은 변수가 아니라 상수, 즉 값 그 자체라고 볼 수 있다.
--> enum class로 정의된 Animal을 보면 값들을 모아놓은 것을 볼 수 있고,
--> enum class로 정의된 Food를 보면 값에 이름을 붙여서 모아놓은 것을 볼 수 있다.
그렇다면 굳이 enum class 를 사용하여 이렇게 값들을 모아놓는 이유를 말하자면
Enum class를 쓰면 인스턴스 생성을 안하기 때문에 이 값에 대한 안정성이 보장된다.
Enum class를 써서 코드내에 연관된 상수들을 모아놓으면,
코드에 대해 프로그래머의 의도를 파악하기 쉽다 ( 협업에 도움이 된다. )
Enum class를 사용하면 코드가 단순해지기 때문에 가독성이 올라간다.
예시 )
이번 앱에서도 이 enum class가 사용되었는데, 아래와 같이 녹음의 상태에 대한 상수값들을 모아놓았다.
package com.example.aop_part2_chapter7
enum class State {
BEFORE_RECORDING,
ON_RECORDING,
AFTER_RECORDING,
ON_PLAYING
}
--> 이번 녹음기 앱에 대한 코드들은 모두 해당 enum class로 모아놓은 상태에 따라 진행되기 때문에
이렇게 상태를 한번에 정의해 놓으면 코드 전체에 대한 가독성이 높아진다.
예를 들어,
녹음전에 앱은 BEFORE_RECORDING상태이며 이 조건에 맞는 코드들이 실행됨
녹음중인 앱은 ON_RECORDING 상태이며 이 조건에 맞는 코드들이 실행됨
녹음후인 앱은 AFTER_RECORDING 상태이며 이 조건에 맞는 코드들이 실행됨
등등
--> 커스텀뷰는 이 상태에 따라 뷰의 모습이 변하게 될것이고
--> 코드에서는 이 상태에 따라 해당 뷰를 제어하는 방식이 정해질 것임
package com.example.aop_part2_chapter7
import android.content.pm.PackageManager
import android.media.MediaPlayer
import android.media.MediaRecorder
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Button
import java.util.jar.Manifest
class MainActivity : AppCompatActivity() {
private val soundVisualizerView : SoundVisualizerView by lazy {
findViewById(R.id.soundVisualizerView)
}
private val recordTimeTextView: CountUpView by lazy {
findViewById(R.id.recoreTimeTextView)
}
private val recordButton: RecordButton by lazy {
findViewById(R.id.recordButton)
}
private val resetButton: Button by lazy {
findViewById(R.id.resetButton)
}
private val recordingFilePath: String by lazy {
"${externalCacheDir?.absolutePath}/recording.3gp"
// 외부 캐시 저장소에 대한 절대주소를 가져옴 --> 뒤부분에 파일명에 대해 확장자가 .3gp인 이유는 녹음기의 포멧이 3gp이기 때문이다.
}
private val requiredPermissions = arrayOf(android.Manifest.permission.RECORD_AUDIO)
private var recorder: MediaRecorder? = null
// MediaRecorder의 경우 정지할때마다 다시 초기화를 해야하기 떄문에
// 사용하지 않을 때는 release( 매모리에서 해제 )시켜버리고 null로 두는편이 관리가 수월하다.
// ( 오디오, 비디오 등의 코스트가 큰 데이터 모두 포함 )
private var player:MediaPlayer? = null
private var state = State.BEFORE_RECORDING
set(value) {
field = value
resetButton.isEnabled = (value == State.AFTER_RECORDING) || (value == State.ON_PLAYING)
recordButton.updateIconWithState(value)
}
// state변수의 setter를 재정의
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
requestAudioPermission()
initViews()
bindView()
initVariables()
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
val audioRecordPermissionGranted = requestCode == REQUEST_RECORD_AUDIO_PERMISSION &&
grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED
if (!audioRecordPermissionGranted){
finish()
}
}
private fun requestAudioPermission(){
requestPermissions(requiredPermissions, REQUEST_RECORD_AUDIO_PERMISSION)
}
private fun initViews(){
recordButton.updateIconWithState(state)
}
private fun bindView(){
// SoundVisualizerView이름의 CustomView에 있는 해당 메소드를 정의
soundVisualizerView.onRequestCurrentAmplitude = {
recorder?.maxAmplitude ?: 0
// recorder가 있으면 ( recorder는 녹음을 시작할때 생성되어 녹음이 끝나면 릴리즈 하므로 recorder가 있다는 것은 녹음중이라는 의미 )
// 해당 recorder에서 현재 의 maxAmplitude를 반환
// 없으면 0 반환
}
resetButton.setOnClickListener {
stopPlaying()
soundVisualizerView.clearVisualization()
recordTimeTextView.clearCountUp()
state = State.BEFORE_RECORDING
}
recordButton.setOnClickListener{
when(state){
State.BEFORE_RECORDING -> {
startRecording()
}
State.ON_RECORDING -> {
stopRecording()
}
State.AFTER_RECORDING ->{
startPlaying()
}
State.ON_PLAYING -> {
stopPlaying()
}
}
}
}
private fun initVariables(){
state = State.BEFORE_RECORDING
}
private fun startRecording(){
recorder = MediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP)
setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB)
setOutputFile(recordingFilePath)
prepare()
}
recorder?.start()
soundVisualizerView.startVisualizing(false)
recordTimeTextView.startCountUp()
state = State.ON_RECORDING
}
private fun stopRecording(){
recorder?.run{
stop()
release()
}
// 해당 객체에 대하여 객체 내부의 변수나 메소드에 람다함수 형식으로 접근할 수 있는 run범위함수를 사용
recorder = null
// stop일 경우 일시적으로 사용하지 않으므로 null로 하여 관리
soundVisualizerView.stopVisualizing()
recordTimeTextView.stopCountUp()
state = State.AFTER_RECORDING
}
private fun startPlaying(){
player = MediaPlayer()
.apply {
setDataSource(recordingFilePath)
prepare()
}
// player가 재생을 완료했을 때 실행되는 리스너
player?.setOnCompletionListener {
stopPlaying()
}
player?.start()
soundVisualizerView.startVisualizing(true)
recordTimeTextView.startCountUp()
state = State.ON_PLAYING
}
private fun stopPlaying(){
player?.release()
player = null
soundVisualizerView.stopVisualizing()
recordTimeTextView.stopCountUp()
state = State.AFTER_RECORDING
}
companion object {
private const val REQUEST_RECORD_AUDIO_PERMISSION = 201
}
}
private val recordingFilePath: String by lazy {
"${externalCacheDir?.absolutePath}/recording.3gp"
}
--> 외부 캐시 저장소에 대한 절대주소를 가져옴
--> 뒤부분에 파일명에 대해 확장자가 .3gp인 이유는 녹음기의 포멧이 3gp이기 때문이다.
private var state = State.BEFORE_RECORDING
set(value) {
field = value
resetButton.isEnabled = (value == State.AFTER_RECORDING) || (value == State.ON_PLAYING)
recordButton.updateIconWithState(value)
}
// state변수의 setter를 재정의
state변수의 setter를 재정의 하는 것으로 state의 변수의 값이 변할 때마다 set()이하의 내용이 실행되도록 설정
state변수는 아래에 설명할 State.kt에서 enum class로 정의한 현재 상태를 담기 위한 변수인데,
state변수에 다른 값이 들어갈 때마다 (즉, 상태가 변화될 때마다) set 이하의 내용이 실행되도록 해놓았음
--> listener처럼 사용하고 있음
또한 아래와 같이 해줘서 state에 따라 다른 ClickListener가 설정되도록 해주었음
recordButton.setOnClickListener{
when(state){
State.BEFORE_RECORDING -> {
startRecording()
}
State.ON_RECORDING -> {
stopRecording()
}
State.AFTER_RECORDING ->{
startPlaying()
}
State.ON_PLAYING -> {
stopPlaying()
}
}
}
......
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
requestAudioPermission()
......
}
......
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
val audioRecordPermissionGranted = requestCode == REQUEST_RECORD_AUDIO_PERMISSION &&
grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED
if (!audioRecordPermissionGranted){
finish()
}
}
private fun requestAudioPermission(){
requestPermissions(requiredPermissions, REQUEST_RECORD_AUDIO_PERMISSION)
}
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.example.aop_part2_chapter7.SoundVisualizerView
android:id="@+id/soundVisualizerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginBottom="10dp"
app:layout_constraintBottom_toTopOf="@+id/recoreTimeTextView"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/resetButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="RESET"
app:layout_constraintBottom_toBottomOf="@id/recordButton"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toLeftOf="@id/recordButton"
app:layout_constraintTop_toTopOf="@id/recordButton" />
<com.example.aop_part2_chapter7.CountUpView
android:id="@+id/recoreTimeTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="20dp"
android:text="00:00"
android:textColor="@color/white"
app:layout_constraintBottom_toTopOf="@id/recordButton"
app:layout_constraintLeft_toLeftOf="@+id/recordButton"
app:layout_constraintRight_toRightOf="@+id/recordButton" />
<!-- 커스텀 뷰는 아래와 같은 방법으로 Layout에 추가 하면 됨-->
<com.example.aop_part2_chapter7.RecordButton
android:id="@+id/recordButton"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_marginBottom="50dp"
android:padding="25dp"
android:scaleType="fitCenter"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
tools:src="@drawable/ic_record" />
</androidx.constraintlayout.widget.ConstraintLayout>
<com.example.aop_part2_chapter7.SoundVisualizerView
android:id="@+id/soundVisualizerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginBottom="10dp"
app:layout_constraintBottom_toTopOf="@+id/recoreTimeTextView"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.example.aop_part2_chapter7.CountUpView
android:id="@+id/recoreTimeTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="20dp"
android:text="00:00"
android:textColor="@color/white"
app:layout_constraintBottom_toTopOf="@id/recordButton"
app:layout_constraintLeft_toLeftOf="@+id/recordButton"
app:layout_constraintRight_toRightOf="@+id/recordButton" />
<com.example.aop_part2_chapter7.RecordButton
android:id="@+id/recordButton"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_marginBottom="50dp"
android:padding="25dp"
android:scaleType="fitCenter"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
tools:src="@drawable/ic_record" />
package com.example.aop_part2_chapter7
enum class State {
BEFORE_RECORDING,
ON_RECORDING,
AFTER_RECORDING,
ON_PLAYING
}
enum class 를 사용해서, 녹음기에서의 단계들을 정의해놓았다.
이를 사용하여 앱이 단계별로 전개되므로 앱의 전체적인 가독성을 높인다.
package com.example.aop_part2_chapter7
import android.content.Context
import android.util.AttributeSet
import android.widget.ImageButton
import androidx.appcompat.widget.AppCompatImageButton
class RecordButton (
context: Context,
attrs: AttributeSet
): AppCompatImageButton(context, attrs) {
// View에서 init을 통해 해당 View의 속성에 접근할 수 있음
init {
setBackgroundResource(R.drawable.shape_oval_button)
}
// 상태를 저장한 enum클래스인 State를 기준으로
// 앱이 어떤 상태( State에서 정의된 상태 )인지에 맞춰 해당 뷰의 모습을 변경하기 위한 메소드
fun updateIconWithState(state: State){
when(state){
State.BEFORE_RECORDING->{
setImageResource(R.drawable.ic_record)
}
State.ON_RECORDING -> {
setImageResource(R.drawable.ic_stop)
}
State.AFTER_RECORDING -> {
setImageResource(R.drawable.ic_play)
}
State.ON_PLAYING -> {
setImageResource(R.drawable.ic_stop)
}
}
}
}
......
class RecordButton (
context: Context,
attrs: AttributeSet
): AppCompatImageButton(context, attrs) {
......
CustomView를 만들기 위해서 Context와 AttributeSet을 파라미터로 받고있으며,
ImageView에 대한 CustomView 이므로 AppCompatImageButton(context, attrs)로 상속받고 있음
( AppCompatImageButton의 파라미터에 들어가는 인자 2개는 CustomView에서 파라미터로 받는 Context와 AttributeSet )
// View에서 init을 통해 해당 View의 속성에 접근할 수 있음
init {
setBackgroundResource(R.drawable.shape_oval_button)
}
init{} 의 내부에서 현재 CustomView의 속성에 접근할 수 있다.
--> 위 코드에서는 setBackgroundResource에 접근했다.
fun updateIconWithState(state: State){
when(state){
State.BEFORE_RECORDING->{
setImageResource(R.drawable.ic_record)
}
State.ON_RECORDING -> {
setImageResource(R.drawable.ic_stop)
}
State.AFTER_RECORDING -> {
setImageResource(R.drawable.ic_play)
}
State.ON_PLAYING -> {
setImageResource(R.drawable.ic_stop)
}
}
}
상태를 저장한 enum 클래스인 State.kt를 기준으로
앱이 어떤 상태( State에서 정의된 상태 )인지에 맞춰 해당 뷰의 모습을 변경하기 위한 메소드
--> 위에서 말했듯이 enum class를 사용하여 가독성을 높인 부분
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<stroke android:width="2dp"
android:color="@color/light_gray"/>
<solid
android:color="@color/gray"/>
</shape>
package com.example.aop_part2_chapter7
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
class SoundVisualizerView(
context: Context,
attrs: AttributeSet?=null
) : View(context,attrs){
// Int를 반환하는 메소드에 대한 변수이며, null 가능
var onRequestCurrentAmplitude: (()->Int)? = null
// 외부(이 CustomView를 사용하는 액티비티)에 있는 값을 내부(CustomView)에 전달하기 위한 메소드
// 따라서 이 메소드의 정의는 외부(이 CustomView를 사용하는 액티비티)에서 이루어짐
private val amplitudePaint = Paint(Paint.ANTI_ALIAS_FLAG)
.apply {
color = context.getColor(R.color.purple_500)
strokeWidth = LINE_WIDTH // 이번 경우 width의 크기는 고정되어 있으므로 Paint에서 지정해줌
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 replayingPosision : Int = 0
// 익명클래스로 Runnable 인터페이스 구현 및 할당
private val visualizeRepeatAction: Runnable = object : Runnable{
override fun run() {
if(!isReplaying) {
// invoke() --> 해당 메소드를 실행시켜줌
// --> 아래와 같이 다른 클래스에서 해당 메소드를 구현한 이후에 메소드를 사용하고 싶을 때, NullSafe를 지키며 실행을 시키기 위해 사용
val currentAmplitude = onRequestCurrentAmplitude?.invoke() ?: 0
// 데이터를 그리는 구조상 새로운 데이터가 리스트의 처음으로 와야되므로 아래와 같이 넣어줌
drawingAmplitudes = listOf(currentAmplitude) + drawingAmplitudes
}else {
replayingPosision++
}
//onDraw() 메소드를 호출하는 메소드 !!!!!!!!!!!!!
// 이 메소드를 사용해줘야 이렇게 변화된 내용에 onDrwa()를 통해 화면에 그려진다.
invalidate()
handler?.postDelayed(this, ACTION_INTERVAL)
// Handler를 사용하여 0.02초마다 해당 쓰래드를 UI쓰래드에 적용시켜서 실행
}
}
// View가 처음 그려지거나, View의 사이즈가 변경되면 실행되는 메소드
// 첫번째 파라미터로 변경된 폭, 두번쨰 파라미터로 변경된 높이, 세번쨰 파라미터로 기존의 폭, 네번쨰 파라미터로 기본의 높이가 인자로 들어온다.
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
drawingWidth = w
drawingHeight = h
}
// 해당 View를 그리기 위한 메소드
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
canvas ?: return
val centerY = drawingHeight / 2f
var offsetX = drawingWidth.toFloat() // 현재 X 지점을 의미 --> 오른쪽에서부터 왼쪽으로 Line이 진행되므로 초기값을 화면의 오른쪽 끝값인 drawingWidth로 설정 ( 시작 위치 )
drawingAmplitudes
.let{ amplitudes ->
if(isReplaying){
amplitudes.takeLast(replayingPosision)
// list.takeLast(Int형) --> 마지막 데이터부터 시작하여 파라미터만큼의 데이터까지 추출하여 리턴
}else{
amplitudes
}
}
.forEach{ amplitude ->
val lineLength = amplitude / MAX_AMPLITUDE * drawingHeight * AMPLITUDE_SIZE_RATE
offsetX -= LINE_SPACE
if (offsetX < 0) return@forEach
// 녹음되는 동안 그려진 Line의 offsetX(x좌표값)에 LINE_SPACE만큼의 값을 빼서 옆으로 이동
// 이때 offsetX 가 음수가 되는 지점이 바로 해당 Line이 앱의 화면을 벗어난 시점임
// 따라서 이때부터는 그려주지 않고 그냥 넘어가도록 설정한 것
// Y값에 대해 화면 중앙을 기준으로 선의 길이만큼 위아래로 해서 직선을 그림
canvas.drawLine(
offsetX,
centerY - lineLength/2,
offsetX,
centerY + lineLength/2,
amplitudePaint
)
}
}
// 외부(이 CustomView를 사용하는 액티비티)에서 내부(CustomView)를 제어하기 위한 메소드들
// 해당 메소드들의 경우 외부에서 이 CustomView를 제어할 떄 사용하므로 private 해주지 않음
fun startVisualizing(isReplaying: Boolean){
this.isReplaying = isReplaying
handler?.post(visualizeRepeatAction)
}
fun stopVisualizing(){
replayingPosision = 0
handler?.removeCallbacks(visualizeRepeatAction)
}
fun clearVisualization(){
drawingAmplitudes = emptyList()
invalidate()
}
companion object{
private const val AMPLITUDE_SIZE_RATE = 0.8F // amplitude의 크기를 얼마로 나타낼 것인지 비율을 조정하기 위해서 임의로 지정
private const val LINE_WIDTH = 10F
private const val LINE_SPACE = 15F
private const val MAX_AMPLITUDE = Short.MAX_VALUE.toFloat() // 32767 --> 진폭 최대값 지정
private const val ACTION_INTERVAL = 20L
}
}
var onRequestCurrentAmplitude: (()->Int)? = null
외부(이 CustomView를 사용하는 액티비티)에서 정의될 메소드이며,
외부(이 CustomView를 사용하는 액티비티)에 있는 값을 내부(CustomView)에 전달하기 위한 메소드
--> 코드상에서는 정의된 것을 전제로 코드를 전개함
하지만, 근본적으로 null인 상황이기 때문에 이대로 전개하면 nullsafe에 의해 오류를 발생시키는데,
그때 사용한 것이 invoke() 메소드임
val currentAmplitude = onRequestCurrentAmplitude?.invoke() ?: 0
invoke() --> 해당 메소드를 실행시켜줌
다른 클래스에서 해당 메소드를 구현한 이후에 메소드를 사용하고 싶을 때,
코드상에서는 해당 메소드가 구현되었다고 전제하고 코딩할 수 밖에 없는데, 그 때 NullSafe를 지키며 실행을 시키기 위해 사용
fun startVisualizing(isReplaying: Boolean){
this.isReplaying = isReplaying
handler?.post(visualizeRepeatAction)
}
fun stopVisualizing(){
replayingPosision = 0
handler?.removeCallbacks(visualizeRepeatAction)
}
fun clearVisualization(){
drawingAmplitudes = emptyList()
invalidate()
}
외부(이 CustomView를 사용하는 액티비티)에서 내부(CustomView)를 제어하기 위한 메소드들
--> 외부에서 이 메소드에 접근할 수 있어야만 이 CustomView를 제어할 때 사용하므로 private 해주지 않음
invalidate()
onDraw() 메소드를 호출하는 메소드 !!!!!!!!!!!!!
--> 이 메소드를 사용해서 onDraw()를 호출해줘야 onDraw()에서 변화된 내용이 화면에 그려진다.
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
drawingWidth = w
drawingHeight = h
}
현재 View의 크기가 변경되었을 때 호출되는 메소드
즉, View가 처음 그려질 때 1번 호출되고, ( 처음 그려질 때도 크기가 변경되었다고 인식 )
이후 View의 크기가 변경될 때마다 호출된다.
그래서 CustomView에서 자주 사용하는 오버라이드 메소드이다.
일반적으로 CustomView에서는 위와 같이 onSizeChanged()를 통해 현재 자신이 그려진 크기를 파악하며,
이후 그 크기를 바탕으로 비율을 맞춰나간다.
package com.example.aop_part2_chapter7
import android.content.Context
import android.os.SystemClock
import android.util.AttributeSet
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 clearCountUp(){
updateCountTime(0)
}
private fun updateCountTime(countTimeSeconds: Int) {
val minutes = countTimeSeconds / 60
val seconds = countTimeSeconds % 60
text = "%02d:%02d".format(minutes, seconds)
}
}