[개인프로젝트] Schedule Manager

Minji Jeong·2022년 7월 8일
1

프로젝트

목록 보기
1/1
post-thumbnail

대학에서의 마지막 학기를 보내며 두 달 정도를 소모한 앱이 드디어 완성되었다. 사실 그 전에도 졸업작품을 만들고나서 '제대로 안드로이드 쪽을 파보자'하는 마음으로 올초 겨울방학 동안 두개의 안드로이드 앱을 만들었었고, 하나는 플레이스토어에 출시까지 했지만.. 지나고나서 보니 마치 안맞는 퍼즐을 어거지로 끼우듯 '어 이게 왜 제대로 되는거지'라는 의문을 가지면서도, 제대로 풀어내지 못한 채 어쨌든 되긴 되니까 하는 마음가짐으로 개발을 했더라. 그러나보니 코드 자체가 불필요하게 늘어나고, 모듈화가 제대로 안되어서 유지보수가 힘들어졌기에 에러가 나도 이걸 어디서부터 고쳐야 할지 정말 난감했었다. 여튼 전에 만들어두었던 두개의 앱들을 다 뜯어 고쳐야겠다고 생각했고, 플레이스토어에 출시했었던 '심스메'부터 리뉴얼을 시작하게 되었다. 그렇게 해서 다시 만들어진게 Schedule Manager로, 디자인을 단순화했고 자잘한 버그들이 수정되었다. 대단한 기능을 가진 건 아니지만, 해당 앱을 개발하면서 개인적으로 회고하는 시간을 가질 겸, 어떠한 라이브러리들이 왜 사용되었는지 다시 상기시키는 겸 이렇게 글을 남기게 되었다. Schedule Manager의 전체 소스코드 및 리드미가 궁금하다면 하단의 링크를 클릭하자.

Schedule Manager

Intro

➕ Schedule Manager를 간단히 소개하자면,
하루 일과를 체크하고, 달력을 통해 일정을 관리할 수 있도록 기능을 제공하는 일정관리 목적의 안드로이드 앱이다.

주요 기능은 다음과 같다.

  • 할 일 추가, 완료, 삭제, 변경 가능
  • 일정 추가, 일정 변경 및 삭제, 알람 설정
👉 관련 포스팅 : 알람 기능 구현하기(생성부터 취소, 기기 재부팅까지)

먼저 심스메(Simple Schedule Maker)와 이번 앱이 달라진 점은 다음과 같다.

  • Kotlin을 사용했다.
  • 커스텀 달력을 사용하지 않고 Material CalendarView를 사용했다.
  • RxJava가 아닌 Coroutine을 사용했다.
  • Jetpack Navigation을 사용했다.
  • DI framework를 사용했다.
  • UI 라이브러리들을 적용했다.

사실 Schedule Manager를 만들면서 참 많은 사실을 새로 알게 되었고, 어느 분야든 마찬가지지만 안드로이드 분야가 정말 공부할 게 많다는 걸 다시 한번 깨닫게 되었다. '아 그래도 전보다는 많이 알고있는 것 같다'고 생각이 들려 하면 몰랐던 사실을 새로 알게 되고, 마치 꿀밤을 한 대 맞는 느낌이 든달까.

개인적으로 내가 그동안 몰라서 신경을 못썼던 게 있는데, 바로 themes.xml에서 수정할 수 있는 앱의 '테마'다. 심스메를 출시하고 나서 안드로이드 핸드폰을 사용하는 지인들에게 한번 설치해서 사용해 줄 수 있냐고 부탁을 한 적이 있는데, 내가 xml에서 설정했던 뷰들의 색깔이 이상하게 나왔고 NoActionBar를 설정했음에도 액션바가 상단에 보였던 것이다. 그 때 문제의 원인이 바로 테마 속성을 제대로 정의해주지 않은 것과, 뷰에서 색상 관련 속성들을 정의해주지 않으면 테마의 색이 적용된다는 것이었다. 따라서 바로 수정을 해서 업데이트했었다.

하지만 그 당시 '다크 테마'의 존재는 몰랐다. 나는 사실 아이폰 유저고, 전에 썼던 갤럭시s8(pie 이후 업데이트가 중단되었다)을 사용해 앱 개발 테스트를 하고 있는데 개발할 때가 아니면 잘 쓰지 않아서 다크 모드를 비활성화 해놨었다. Schedule Manager를 만들면서 '아, 이거 다크모드로 설정해야겠다'는 생각이 문득 들었고, 그 때 다크모드를 적용하면서 내가 뭘 또 놓치고 있었구나..라는 걸 깨닫게 되었다.

    <style name="Theme.Newcalendar" parent="Theme.MaterialComponents.NoActionBar">
        <!-- Primary brand color. -->
        <item name="colorPrimary">@color/classicBlue</item>
        <item name="colorOnPrimary">@color/white</item>
        <item name="colorSecondary">@color/white</item>
        <item name="colorOnSecondary">@color/black</item>
        <item name="colorPrimaryVariant">@color/darkBlue</item>
        <item name="colorSecondaryVariant">@color/white</item>
        <item name="colorSurface">@color/white</item>
        <item name="colorOnSurface">@color/black</item>
        <item name="android:colorBackground">@color/white</item>
        <item name="colorError">@color/red</item>
        <item name="android:textColorPrimary">@color/white</item>
        <item name="android:fontFamily">@font/sc_dream4</item>
        <!-- Status bar color. -->
        <item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
    </style>

values 폴더의 themes.xml에선 다크모드 비활성화 시 디폴트로 적용되는 테마를, values-v31 폴더의 themes.xml에선 API 레벨 31 이상의 기기에서 다크모드 비활성화 시 디폴트로 적용되는 테마를, values-night에선 다크모드 활성화 시 디폴트로 적용되는 테마를 작성하면 된다. 사실 사용자가 다크모드로 설정했을 때는 그에 따라 앱의 테마도 다크모드에 걸맞게 설정해주는것이 좋지만, Schedule Manager의 기본 테마 색은 남색 계열이었기 때문에 딱히 다크모드로 적용해주지 않아도 될 것 같기도 했고, 또 다크모드 디자인은 어떻게 해야할지 감이 안잡히기도 해서..여차저차 테마는 그냥 통일하기로 했다.

자 그럼 서론은 여기서 끝내고, 본론으로 넘어가며 심스메에 비해 달라진 점들을 하나하나 풀어보겠다.

1. Kotlin 사용

심스메는 자바로 작성되었다. 사실 난 작년 말까지만 해도 코틀린의 코자도 모르던 사람이었다. 작년 즈음 코틀린을 사용해보자며 호기롭게 도전한 경험은 있지만, 낯선 문법들과 촉박한 시간에 부딪혀 다시 자바로 돌아가게 되었다.

하지만 올초 코틀린의 중요성을 알게 되었고(Compose의 경우는 코틀린만 사용 가능) 더 이상 자바만 잡고 있을 순 없어서 급하게 코틀린을 공부하기 시작했다. 내가 처음으로 코틀린으로만 개발한 앱이 '푸디어리'라는 식단기록 목적의 앱인데, 이 역시 심스메처럼 안맞는 퍼즐을 억지로 끼우듯이 개발했기 때문에 코드가 난리가 난 상태다.

코틀린의 장점 하면 자바보다 간결한 코드를 작성할 수 있다는 것과 함수형 프로그래밍을 지원한다는 것 아니겠는가. 푸디어리를 만들면서 코틀린의 장점들을 제대로 활용하지 못했기 때문에 이러한 부족함을 Schedule Manager를 개발하면서 채우고 싶었다. 이번엔 범위지정함수도 공부하며 적절한 곳에 적용하고자 많은 고민을 했다.

👉 관련 포스팅 : Kotlin Scope functions

2. Material CalendarView 사용

심스메도 처음엔 사용하기 편리한 Material CalendarView를 사용했었으나, 디자인의 한계로 인해 다른 달력 라이브러리의 필요성을 느꼈다. 구글링을 해서 여러 달력 라이브러리들과 소스코드들을 찾아보다가 괜찮은 코드를 발견하고 달력을 교체했었는데, 내가 쓴 코드가 아니다보니 달력 UI 관련한 버그를 수정하기가 힘들었다. 중간에 Kizitonwose CalendarView도 사용해봤으나, 편리한 사용법과 단순하지만 깔끔한 디자인이 계속 생각나서 Schedule Manager에서 다시 Material CalendarView를 사용하게 되었다.

<com.prolificinteractive.materialcalendarview.MaterialCalendarView
	android:id="@+id/calendarView"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:layout_margin="10dp"
    app:layout_constraintTop_toBottomOf="@id/toolbar"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:mcv_selectionColor="#5a7fbf"
    app:mcv_headerTextAppearance="@style/CalendarWidgetHeader"
    app:mcv_weekDayTextAppearance="@style/CalendarWidgetWeek"
    app:mcv_dateTextAppearance="@style/CalendarWidgetDay"
    app:mcv_arrowColor="@color/white"
    tools:ignore="MissingConstraints" />

Material CalendarView는 토/일요일에 해당하는 날짜들의 색을 변경하거나 일정이 저장된 날짜에 도트를 찍을 수 있는 Decorator 클래스를 제공한다. 다만 SaturdayDecorator와 SundayDecorator의 shouldDecorate 내에서 사용된 copyTo라는 메서드가 MaterialCalendarView 최신 버전에서는 지원이 안되는지 사용할 수가 없어서 라이브러리 버전을 1.6.0으로 낮춰야만 했다. 또 마지막 업데이트가 2019년이라 🤔 조만간 deprecated 될 수도 있겠다는 생각이 들었다. 어쨌든, 여전히 훌륭한 달력 라이브러리임에는 틀림 없다!

class SaturdayDecorator : DayViewDecorator {

    private val calendar = Calendar.getInstance()

    override fun shouldDecorate(day: CalendarDay): Boolean {
        day.copyTo(calendar)
        val weekDay: Int = calendar.get(Calendar.DAY_OF_WEEK)
        return weekDay == Calendar.SATURDAY
    }

    override fun decorate(view: DayViewFacade) {
        view.addSpan(ForegroundColorSpan(Color.parseColor("#87CEFA")))
    }
}

일정이 있는 날짜에 도트를 찍기 위해선 로컬 DB를 활용해야 했다. 따라서 일정이 저장된 날짜 데이터를 문자열 형태로 저장하는 테이블을 정의하고, 저장된 날짜 데이터를 가져올 때 LiveData 형태로 반환받아서 프래그먼트에 주입한 뷰모델 클래스에서 관찰할 수 있도록 했다. 날짜 데이터는 2000-00-00 형식으로 되어 있기에 년/월/일 형식으로 쪼갠 후, 생성한 EventDecorator 인스턴스를 addDecorator를 사용해 calendarView에 추가했다.

class EventDecorator() : DayViewDecorator {

    private var color = 0
    private lateinit var dates : HashSet<CalendarDay>

    constructor(color: Int, dates: Collection<CalendarDay>) : this() {
        this.color=color
        this.dates=HashSet(dates)
    }

    override fun shouldDecorate(day: CalendarDay?): Boolean {
        return dates.contains(day)
    }

    override fun decorate(view: DayViewFacade?) {
        view?.addSpan(DotSpan(10F, color))
    }
}

...

class CalendarFragment : Fragment() {

	override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        // 일정 있는 날짜에 도트 찍기
        viewModel.getAllDates().observe(viewLifecycleOwner, androidx.lifecycle.Observer { list ->
            for (i in list.indices){
                val eventDate = list[i].date.split("-")
                val year = Integer.parseInt(eventDate[0])
                val month = Integer.parseInt(eventDate[1])
                val day = Integer.parseInt(eventDate[2])
                binding.calendarView
                    .addDecorator(
                        EventDecorator(
                            Color.parseColor("#0E406B"),
                            Collections.singleton(CalendarDay.from(year, month-1, day))))
            }
        })
    }
}

3. RxJava가 아닌 Coroutine 사용

심스메에서는 RxJava를 사용해 비동기 프로그래밍을 수행했다. 하지만 RxJava에 대해 제대로 알고 사용한 건 아니였고, RxJava도 물론 좋지만 코루틴을 좀 더 사용해보고 싶어서 코루틴을 사용하게 되었다. Schedule Manager에서 코루틴을 사용할 부분은 로컬 데이터베이스에 접근할 때와 DataStore를 사용할 때였다.

Fragment에서 해당 작업을 할 때는 LifecycleScope를 사용했다. LifecycleScope는 LifeCycleOwner의 lifecycle에 엮여있기 때문에 lifecycle이 destroyed되면 코루틴 작업도 자동으로 취소되어서 특수한 상황이 아니라면(ex retrofit을 사용해 많은 양의 원격 데이터를 받아오는 동안 홈버튼을 눌러 앱을 나갔을 때, onStop()에서 작업을 중지해주지 않으면 사용자가 앱을 사용하지 않아도 작업이 계속 실행됨) 별도로 관리를 해주지 않아도 되기 때문이다. 하지만 기본적으로 Dispatcher.Main(메인 쓰레드)를 사용하기 때문에, UI 관련 작업이 아니라면 withContext()를 사용해서 작업 쓰레드를 변경해줘야 한다.

class MemoFragment : Fragment() {

  	override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
  		super.onViewCreated(view, savedInstanceState)

        binding.saveBtn.setOnClickListener {
            val memo = binding.memoEdit.text.toString()
            if (memo.isNotEmpty()){
                lifecycleScope.launch {
                    withContext(Dispatchers.IO){
                        viewModel.addMemo(MemoDataModel(serialNum, memo, false))
                    }
                }
            } else {
                Toast.makeText(requireContext(), "내용을 입력해주세요", Toast.LENGTH_SHORT).show()
            }
        }

    }
}
// 선택된 날짜 가져오기
lifecycleScope.launch {
	selectedDate = dateSaveModule.date.first()
    binding.dateText.text = selectedDate
}

4. Jetpack Navigation 사용

보통 여러개의 화면을 구현한다면 호스트 액티비티 한 두개에 여러개의 프래그먼트를 만들어서 사용하는 편인데, 프래그먼트 간 전환에 Navigation을 사용하니 더욱 편리하게 구현할 수 있었다.

👉 관련 포스팅: Jetpack Navigation

기존 코드

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }

    class ItemSelectedListener implements BottomNavigationView.OnNavigationItemSelectedListener {

        @Override
        public boolean onNavigationItemSelected(@NonNull MenuItem item) {
            FragmentTransaction fTransaction = fragmentManager.beginTransaction();
            switch (item.getItemId()){
                case R.id.navi_calendar:
                    fTransaction.replace(R.id.frame_layout, fragmentCalendar).commitAllowingStateLoss();
                    break;
                case R.id.navi_upcoming:
                    fTransaction.replace(R.id.frame_layout, fragmentUpcoming).commitAllowingStateLoss();
                    break;
                case R.id.navi_timer:
                    fTransaction.replace(R.id.frame_layout, fragmentTimer).commitAllowingStateLoss();
            }
            return true;
        }
    }
}

Navigation을 적용한 코드
BottomNavigationView가 아닌 SmooothBottomBar라는 하단바 라이브러리를 사용했는데, 해당 라이브러리는 하단바 메뉴 클릭 시 Navigation으로 화면을 전환할 수 있도록 기능을 지원해줘서 더욱 유용하게 활용할 수 있었다.

class MainActivity : AppCompatActivity(R.layout.activity_main) {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val navHostFragment = supportFragmentManager.findFragmentById(R.id.fragmentContainer) as NavHostFragment
        navController = navHostFragment.navController

        setupSmoothBottomMenu()
    }

    private fun setupSmoothBottomMenu() { // smooth bottomBar & navController 연결
        val popupMenu = PopupMenu(this, null)
        popupMenu.inflate(R.menu.menu)
        val menu = popupMenu.menu
        binding.bottomBar.setupWithNavController(menu, navController)
    }
}

5. DI framework 사용

DI 프레임워크인 Koin을 사용해서 ViewModel, Local Database, DataStore를 모듈화한 뒤 필요한 컴포넌트에 주입해서 사용했다.

👉 관련 포스팅 : Dependency Injection with Koin

기존 코드

public class CalendarPage extends Fragment {

	private ViewModel viewModel;
    
	@Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
    
    	viewModel = new ViewModelProvider(this, new ViewModelFactory(getActivity().getApplication())).get(ViewModel.class);
    
    }
}
public class ViewModelFactory implements ViewModelProvider.Factory {
    private Application application;
    public ViewModelFactory(Application application){
        this.application = application;
    }

    @NonNull
    @Override
    public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
        return (T) new com.simsme.mycustomcalendar.calendar.ViewModel(application);
    }
}

Koin을 적용한 코드

class ViewModel() : ViewModel(){
	...
}
val viewModel = module {
    viewModel {
        ViewModel()
    }
}
class App : Application() {

    companion object{
        private lateinit var app : App
    }

    override fun onCreate() {
        super.onCreate()
        app = this
        startKoin {
            androidContext(this@App)
            modules(
                viewModel
            )
        }
    }
}
private val viewModel : ViewModel by inject()

6. UI 라이브러리 적용

앱을 개발하다보니 기본적으로 제공되는 것들(특히 토스트)의 디자인 면에서 많은 아쉬움을 느꼈고, 좀 더 생기있는(?) 앱을 만들기 위해 토스트 & 하단바 관련 라이브러리들을 사용했다.

1. Toast Library : StyleableToast

토스트 라이브러리 여러개를 사용하다가 StyleableToast에 정착했다. 다른 라이브러리들에 비해 토스트 색상, 폰트, 아이콘까지 개발자가 원하는 대로 디자인할 수 있었기 때문이었다.

원하는 토스트 디자인을 위해 속성을 정의하고,

<style name="myToast">
	<item name="stTextBold">true</item>
    <item name="stTextColor">@color/mainColor</item>
    <item name="stFont">@font/dosis</item>
    <item name="stTextSize">14sp</item>
    <item name="stColorBackground">@color/white</item>
    <item name="stSolidBackground">true</item>
    <item name="stStrokeWidth">3dp</item>
    <item name="stStrokeColor">#fff</item>
    <item name="stIconStart">@drawable/favorite</item>
    <item name="stIconEnd">@drawable/favorite</item>
    <item name="stLength">SHORT</item> LONG or SHORT
    <item name="stGravity">top</item> top or center
    <item name="stRadius">20dp</item>
</style>

선언해주면 끝!

StyleableToast.makeText(requireContext(), "Welcome", R.style.joinToast).show()

이전에 3종류의 토스트 라이브러리들에 대해 리뷰한 적이 있었는데, 다른 라이브러리들도 궁금하다면 하단의 링크를 클릭해서 확인해보자!

👉 관련 포스팅 : 3 Toast Libraries

2. BottomBar Library : SmoothBottomBar

원래는 AnimatedBottomBar 라는 것을 사용했었으나, SmoothBottomBar의 존재를 알고 디자인 면에서 좀 더 예쁜 것 같아 라이브러리를 교체했었다.

<me.ibrahimsn.lib.SmoothBottomBar
      android:id="@+id/bottomBar"
      android:layout_width="match_parent"
      android:layout_height="60dp"
      app:layout_constraintBottom_toBottomOf="parent"
      app:backgroundColor="@color/classicBlue"
      app:menu="@menu/menu"
      tools:ignore="MissingConstraints" />
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val navHostFragment = supportFragmentManager.findFragmentById(R.id.fragmentContainer) as NavHostFragment
        navController = navHostFragment.navController

        setupSmoothBottomMenu()
    }

    private fun setupSmoothBottomMenu() { // smooth bottomBar & navController 연결
        val popupMenu = PopupMenu(this, null)
        popupMenu.inflate(R.menu.menu)
        val menu = popupMenu.menu
        binding.bottomBar.setupWithNavController(menu, navController)
    }


추가로, ViewBinding 객체를 해제하기 위한 라이브러리를 사용하기도 했다. 프래그먼트 내에서 ViewBinding 객체를 생성하고 메모리를 해제하는 것은 매우 중요한데, ViewBinding 객체는 프래그먼트 내에서 레이아웃에 정의된 모든 뷰들을 참조하기 때문에 메모리를 제때 해제해주지 않으면 프래그먼트가 소멸될 때까지 계속 남아있어서 메모리 릭이 발생할 수 있기 때문이다. ViewBinding 관리에 사용한 라이브러리는 ViewBindingPropertyDelegate이다.

👉 관련 포스팅 : ViewBindingPropertyDelegate

✅ 후기

Git branch를 잘못 사용하다 🤣
git branch도 종류가 main, develop, release 등 여러개가 있고, 각각 목적에 맞게 만들어서 사용해야 한다는 걸 이전에는 잘 몰랐다. 그래서 그동안은 무조건 master 브랜치만 사용해서 커밋하고 푸시하고 그랬는데, 혼자서 개발하면 상관은 없겠지만 그래도 협업하듯 사용하고 싶어서 이번 Schedule Manager에서는 develop 브랜치를 따로 만들어서 작업했었다. 그런데 문제는 develop 브랜치로 계속 개발을 하다가 앱이 완성되었을 때 main 브랜치에 병합해서 사용해야 하는데, 사용법을 제대로 숙지하지 못한 상태였기 때문에 develop 브랜치에 작업한 내용을 매번 master 브랜치에 병합하고 있었던 것이다. 이래서 제대로 아는 상태에서 뭘 해야 하는건데 😥 최근에 새로 시작한 프로젝트에서는 같은 실수를 반복하지 않을 것이다.

결론
이러한 과정들을 거쳐 Schedule Manager가 완성되었다. 가독성 좋은 코드와 외부 라이브러리들을 안정적으로 사용하기 위해 혼자서 많이 고민했고, 공들였지만 현업에서 일하고 있는 개발자분들이 본다면 아직 많이 부족해 보일 것이다. 이전에 만들었던 심스메와 비교해보면 스스로가 보기에 전보다는 확실히 나아지긴 했다. ㅎㅎ; 어쨌든 아직 많이 부족하다는 것을 늘 마음 속에 새기며, 정답은 없지만 정답에 가깝게 나아가기 위해 더욱 더 열심히 안드로이드 개발을 해야겠다. 두번째 프로젝트가 완료되면 그 때도 이렇게 개발 후기를 작성해야겠다!

profile
Mobile Software Engineer

1개의 댓글

comment-user-thumbnail
2023년 1월 18일

안녕하세요. 달력 구현에 대해서 질문드리고 싶은게 있는데,
오픈채팅방에서 질문드려도 되는지 문의드립니다.

https://open.kakao.com/o/sDOiewZe

답글 달기