앱 프로젝트 - 10 - 1 (오늘의 명언) - Firebase Remote Config( 원격 구성 ), ViewPager2

소개

  • 코드 수정 없이 명언을 추가할 수 있다.
  • 코드 수정 없이 이름을 숨길 수 있다.
  • 무한 스와이프 할 수 있다.

활용 기술

  • Firebase Remote Config
  • ViewPager2

//////////

  1. 프로젝트 셋업하기
  2. 기본 UI 구성하기
  3. Remote Config 구성하기
  4. Remote Config 연동하기
  5. 완성도 높이기

레이아웃 소개


알아야 할 내용

ViewPager2

공식문서 : https://developer.android.com/reference/androidx/viewpager2/widget/ViewPager2?hl=en

ViewPager2 란?

ViewPager에서 개선된 버전의 클래스

실무에서는 아직까지 ViewPager를 더 많이 사용함,
( 기존의 legacy에서 ViewPager를 사용해왔고,
완전히 ViewPager2로 전환하기에는 ViewPager2가 아직 미흡한 부분이 많기 떄문 )

하지만 ViewPager2는 지속적으로 업데이트가 이루어지고 있는데 반해
ViewPager는 지원이 더이상 없기 때문에 결과적으로는 ViewPager2로 이전될 것으로 예상됨

따라서 현 상황에서는 ViewPager와 ViewPager2 를 모두 알고 있는 것이 바람직함


ViewPager2가 ViewPager에 비해 가지는 장점

공식문서 : https://developer.android.com/training/animation/vp2-migration

  • Vertical orientation support
    기존의 ViewPager에서는 좌우 스와이프만 되었음
    ViewPager2에 와서는 설정을 통해 세로 스와이프도 가능

  • Right-to-left(RTL) support
    언어에 따른 RTL 설정이 가능함

  • Modifiable fragment collections
    기존의 ViewPager에서는 notifyDatasetChanged()메소드가 제대로 기능하지 않는 경우가 많았음,
    ViewPager2에서는 이런 부분들에 대한 개선이 이루어짐

  • DiffUtil
    기존의 ViewPager와 다르게 ViewPager2는 RecyclerView를 기반으로 만들어졌음
    --> 따라서 Collection을 보여주는 RecyclerView의 장점인 DiffUtil을 사용해서
    애니메이션 같은 부분들을 좀 더 원할하게 보여줄 수 있음


일반적인 ViewPager2 구성하기

ViewPager2는 일반적으로

Adapter
ViewPager2의 레이아웃

만들어야 한다.

activity_main.xml 코드에서 ViewPager2 사용

    <androidx.viewpager2.widget.ViewPager2
        android:id="@+id/viewPager"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

item_quote.xml 코드 -> ViewPager2의 레이아웃 그리기 예시

<?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">


    <TextView
        android:id="@+id/quoteTextView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginHorizontal="40dp"
        android:ellipsize="end"
        android:gravity="end|center_vertical"
        android:maxLines="6"
        android:textSize="30sp"
        app:layout_constraintBottom_toTopOf="@+id/nameTextView"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.4"
        app:layout_constraintVertical_chainStyle="packed"
        tools:text="나는 생각한다 고로 존재한다." />

    <TextView
        android:id="@+id/nameTextView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="15dp"
        android:ellipsize="end"
        android:gravity="end"
        android:maxLines="1"
        android:textSize="20sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="@id/quoteTextView"
        app:layout_constraintStart_toStartOf="@id/quoteTextView"
        app:layout_constraintTop_toBottomOf="@id/quoteTextView"
        tools:text="데카르트" />

</androidx.constraintlayout.widget.ConstraintLayout>

[ item_quote.xml이 그리는 ViewPager2의 레이아웃 ]

Quote.kt --> ViewPager2에서 사용할 리스트에 대한 데이터객체 정의

package com.example.aop_part3_chapter10

data class Quote(
    val quote: String,
    val name: String
)

--> 데이터 클래스로 정의하여 데이터의 형태를 정함
--> 이제 이 포멧으로 List에 데이터가 담겨서 Adapter클래스로 전달되며,
ViewPager2에서 보여줄 것임

QuotesPagerAdapter.kt --> ViewPager2의 Adapter 파일

package com.example.aop_part3_chapter10

import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.TextureView
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView

// 1. Adapter에 데이터 리스트( Quote는 데이터클래스로 정의했으며, 이것을 담는 리스트 )를 넣어서 ViewPager2에 Adapter를 세팅
class QuotesPagerAdapter(
    private val quotes: List<Quote>,
    private val isNameRevealed: Boolean

) : RecyclerView.Adapter<QuotesPagerAdapter.QuoteViewHolder>() {

    // 2. ViewHolder를 생성 -> 파라미터로 ViewPager2를 구성할 레이아웃을 Inflater로 가져와서 넣음
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
        QuoteViewHolder(
            LayoutInflater.from(parent.context)
                .inflate(R.layout.item_quote, parent, false)
        )



// 6. 5에서 생성한 각각의 item_quote_View에 ( bind() 메소드를 이용하여 ) 데이터 리스트의 데이터를 하나씩 레이아웃에 적용시켜줌
    override fun onBindViewHolder(holder: QuoteViewHolder, position: Int) {
    
        holder.bind(quotes[position], isNameRevealed)

    }

// 5. 해당 메소드의 반환값( 데이터 리스트의 크기 )만큼의 item_quote_View( 내가 ViewPager2의 레이아웃으로 사용하기 위해 만든 item_quote.xml파일 )를 ViewPager2에 생성함
    override fun getItemCount() = quotes.size

// 3. onCreateViewHolder()에 의해 해당 ViewHoler 객체가 레이아웃을 파라미터로 받아서 생성됨
    class QuoteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

        // 4. 인자로 받은 레이아웃의 각 컴포넌트 주소값을 가져와서 세팅
        private val quoteTextView: TextView = itemView.findViewById(R.id.quoteTextView)
        private val nameTextView: TextView = itemView.findViewById(R.id.nameTextView)


        fun bind(quote: Quote, isNameRevealed: Boolean) {
            quoteTextView.text = quote.quote

            if (isNameRevealed) {
                nameTextView.text = quote.name
                nameTextView.visibility = View.VISIBLE
            } else {
                nameTextView.visibility = View.GONE
            }

        }

    }
}
  • class QuotesPagerAdapter( ...... ) 클래스 정의하는 부분

    • RecyclerView.Adapter<>()를 상속
      ViewPager2는 RecyclerView를 상속받기 때문에
      ViewPager2의 Adapter 또한 RecyclerView.Adapter<>()로 RecyclerView의 Adapter를 상속받는다.

    • 생성한 Adapter클래스의 파라미터로 ViewPager를 구성하는데 필요한 데이터들이 들어간다.
      예를 들어 ))
      위 코드에 첫번째 파라미터인 quotes: List<Quote>는
      Adapter에서 사용할 데이터 리스트가 들어가 있다
      ( 해당 제네릭 타입인 Quote는 위에서 정의한 데이터의 형태 -> Quote객체가 리스트에 담겨서 넘어옴 )

      그리고 두번째 파라미터인 isNameRevealed: Boolean는 Adapter를 통해
      레이아웃을 제어하여 이름을 나타내는 TextView의 visability를 바꿀지 여부에 대한 값이다.

    • Adapter<>()의 제네릭타입으로 Adapter내부에서 정의할 ViewHolder클래스가 들어간다.
      --> Adapter클래스는 결국 내부에 ViewHolder클래스를 정의하고ㅡ,
      ViewPager2가 생성될 때, 전달받은 데이터 리스트 크기만큼의 ViewHolder를 생성,
      사용자가 ViewPager2를 스와이프할 때 ViewHolder의 메소드를 실행시키는 클래스라고 볼 수 있다.
  • class QuoteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) -> 뷰홀더 클래스 정의하는 부분

    • Adapter클래스 내부에 정의하는 ViewHolder클래스이다.

    • ViewHolder클래스는 내부에서 레이아웃을 다뤄야하기 때문에
      생성시 ViewPager2를 구성할 레이아웃을 받아야한다.
      따라서 View타입을 파라미터로 받는다.

    • Adapter클래스와 동일하게 RecyclerView.ViewHolder()로
      RecyclerView의 ViewHolder()메소드의 반환값을 상속받으며,
      여기서 ViewHolder() 메소드의 파라미터로 ViewPager2를 구성할 레이아웃을 전달해야 한다.

    • ViewHolder 내부에선 ViewPager2를 구성할 레이아웃을 파라미터로 받았다는 전제하에
      해당 레이아웃의 각 컴포넌트에 접근한다.

        private val quoteTextView: TextView = itemView.findViewById(R.id.quoteTextView)
         private val nameTextView: TextView = itemView.findViewById(R.id.nameTextView)
    • ViewHolder 내부에선 ViewPager2의 레이아웃에
      데이터리스트의 한 데이터( 파라미터로 들어온 )를 적용하기 위한 bind()메소드를 정의한다.
      이 때 bind() 메소드는 파라미터로 변경될 데이터를 받아야 한다.

             fun bind(quote: Quote, isNameRevealed: Boolean) {
             quoteTextView.text = quote.quote
             ......
             
             }

      --> 이번 ViewPager2에선 데이터를 Quote 데이터 클래스로 정의하여 주고 받기로 했다.
      ( 위에서 Quote 데이터 클래스 정의함 )
      따라서 quote 파라미터의 인자로, 스와이프시에 변경된 Quote타입 데이터가 들어올 것이라는
      전제로 코드를 진행

  • Adapter가 구현해야하는 메소드 1) onCreateViewHolder()

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
        QuoteViewHolder(
            LayoutInflater.from(parent.context)
                .inflate(R.layout.item_quote, parent, false)
        )
    • ViewPager2가 생성될 때, 실행되는 메소드

    • LayoutInflater를 통해 ViewPager2를 구성할 레이아웃을 가져오고,
      그 레이아웃을 파라미터에 넣어 ViewHolder를 생성

    • 그렇게 생성된 ViewHolder를 반환해야 한다.

  • Adapter가 구현해야하는 메소드 2) onBindViewHolder()

       override fun onBindViewHolder(holder: QuoteViewHolder, position: Int) {
    
           holder.bind(quotes[position], isNameRevealed)
    
       }
    • ViewHolder의 bind() 메소드를 이용하여
      데이터 리스트의 데이터의 데이터를 각각의 View에 적용해줌
      --> ViewPager2가 생성되면 getItemCount() 메소드가 반환하는 값만큼의 View를 ViewPage2 안에 생성함
      그 View에다가 데이터 리스트의 데이터들을 bind() 메소드를 이용해 하나씩 대입해주는 것
      ( 이에 대한 자세한 설명은 아래에 getItemCount()설명부분에 있음 )

    • 첫번째 파라미터로 위의 onCreateViewHolder() 메소드에서 반환한 ViewHolder를 가져온다.

    • 두번째 파라미터로 현재 ViewPager2의 Index를 가져온다.
      ( 몇번째 데이터를 보여주고 있는지 )

  • Adapter가 구현해야하는 메소드 3) getItemCount()

       override fun getItemCount() = quotes.size
    • ViewPager2가 생성되면 getItemCount() 메소드가 반환하는 값만큼의 View를 ViewPage2 안에 생성함

    • 따라서 일반적인 경우 해당 메소드는 데이터 리스트의 전체 크기를 넣게 됨

    • 아래의 그림과 같이 getItemCount()의 반환값만큼의 View가 생성되어
      ( ViewHolder를 통해 레이아웃이 적용된 채로 ) ViewPager2에 들어가게 되며,
      여기에 데이터 리스트의 각각의 데이터가 ViewHolder의 Bind() 메소드를 통해 해당 View에 적용되는 것이다.

  • Adapter의 흐름을 잘 파악하기 힘들다면, 위 코드의 주석을 1~ 6까지 따라가보기 바람

MainActivity.kt -> Adapter를 ViewPager2에 세팅

......

    private val viewPager: ViewPager2 by lazy {
        findViewById(R.id.viewPager)
    }
    
    ......
    
// 대략 Quote에 대한 List를 구성하거나 받아오는 코드
// val quoteList = emptyList()
// quoteList = ~~~~
    
    ......
    
            viewPager.adapter = QuotesPagerAdapter(
            quotes = quoteList,
            isNameRevealed = isNameRevealed
        )

--> 이런 식으로 ViewPager2에 Adapter를 Setting 해주면 된다.


ViewPager2의 스와이프 했을 때의 효과 변형

ViewPager2에 setPageTransformer{} 람다함수를 통해 효과를 변형할 수 있음
--> 아래쪽에 예제가 있음

공식문서 : https://developer.android.com/reference/androidx/viewpager2/widget/ViewPager2.PageTransformer?hl=en

ViewPager2에서는 View를 넘기기위해 옆으로 스와이프했을 때의 효과에 변화를 줄 수 있다.

일반적으로 크게 3가지 정도로 변형을 줘서 효과를 구현한다.

  • Translation( 가로 세로로 어떻게 이동하느냐에 대한 값 )
  • Scale( 얼마나 커지느냐 )
  • Alpha( 투명도 )

Translation에 대한 내용을 말하자면,
ViewPager2에서 View의 index는 다음과 같이 인식한다.

  • 현재 위치를 0으로 보고 왼쪽을 음수, 오른쪽을 양수로 인식한다.

  • 만약에 사용자가 화면을 스크롤해서 오른쪽의 View가 메인이 되었다면,
    1이었던 View는 0으로, 메인에 있어서 0이었던 View는 -1로 가는 등의 식으로 이해하면 된다.

예시 ) Alpha 값을 조정하여 변화를 줄 것임,

[ 효과 변형 없는 기존의 Alpha값 표 ]
--> 메인화면이 있는지 여부 상관 없이 Alpha값은 1로 동일했음

[ 알파값에 변화를 줌 ]
메인화면에 오면 alpha값이 0 -> 1 로 서서히 오르고,
메인화면에서 멀어지면 1 -> 0 으로 서서히 내려가는 효과 변형을 줄 것이다.


    private val viewPager: ViewPager2 by lazy {
        findViewById(R.id.viewPager)
    }

......

        viewPager.setPageTransformer { page, position ->

            when {
                position.absoluteValue >= 1.0F -> {
                    page.alpha = 0F
                }
                position == 0F -> {
                    page.alpha = 1F
                }
                else -> {
                    page.alpha = 1F - position.absoluteValue
                }
            }
        }

이런 느낌으로 ViewPager2에 효과를 주면 된다.
--> 여기서 absoluteValue는 절대값을 반환한다.

[ 좀 더 변형한 예제 ]
--> 위와 같이 했더니 alpha가 0이 되는 순간이 화면을 벗어나는 순간이라서 효과가 뚜렷하지 않았음
--> 따라서 alpha값의 변화를 좀 더 빨리줘서 효과가 뚜렷하게 만듬


    private val viewPager: ViewPager2 by lazy {
        findViewById(R.id.viewPager)
    }

......

        viewPager.setPageTransformer { page, position ->

            when {
                position.absoluteValue >= 1.0F -> {
                    page.alpha = 0F
                }
                position == 0F -> {
                    page.alpha = 1F
                }
                else -> {
                    page.alpha = 1F - 2 * position.absoluteValue
                }
            }
        }

ViewPager2 무한 스크롤

이 부분은 무한 스크롤을 만드는 방법론에 대한 부분이다.

위에서 했던 부분에서 조금 변형을 주면된다.

먼저 방법론을 말하자면,

  • 아래와 같이 View를 무수히 많게 만들어 놓은 뒤,

  • 데이터 리스트의 범위를 넘어가면, 다시 데이터 리스트의 처음 부분부터 보여주도록 만들면 된다.

  • 또한 기존과는 다르게 무수히 많이 많든 View의 중간번째부터 ViewPager2의 메인이 시작되어야 한다.
    ( 1번부터 시작하면 시작시에 왼쪽 스크롤이 불가능하기 때문 )

[ 기존의 방식 ]

[ 무한 스크롤을 구현하는 방식 ]

무한 스크롤 구현하기

위에서 ViewPager2를 구현했다는 전제를 하고ㅡ,
이를 수정하는 식으로 설명하겠음

  • 먼저 Adapter의 아래 부분에 변화를 줌

       override fun onBindViewHolder(holder: QuoteViewHolder, position: Int) {
    
           val actualPosition = position % quotes.size
           holder.bind(quotes[actualPosition], isNameRevealed)
           
    //     holder.bind(quotes[position], isNameRevealed)
       }
    
        override fun getItemCount() = Int.MAX_VALUE
    //  override fun getItemCount() = quotes.size
    
    • getItemCount()를 Int로 표현할 수 있는 최대값으로 설정
      --> getItemCount()의 리턴값만큼 View가 만들어지므로 이렇게 하면 무수히 많은 View가 만들어짐
      --> 무한 스크롤이라고 했지만, 진짜 무한인 것이 아니라 끝에 다다르지 못할 정도로 많이 만드는 것

    • onBindViewHolder의 현재 View의 index를 나타내는 값에 대해
      % 연산을 하여 무한히 돌려줌
      예를 들어 ))
      List의 크기가 5라고 할때,
      0, 1, 2, 3, 4는 그대로 갈 것이며,
      5은 5 % 5 = 0
      6은 6 % 5 = 1
      7은 7 % 5 = 2
      ....
      이런식으로 무한히 데이터 리스트를 반복해서 View에 넣어주게 된다.

  1. ViewPager2에 설정을 추가

       private val viewPager: ViewPager2 by lazy {
           findViewById(R.id.viewPager)
       }
       
        ......
    
               val adapter = QuotesPagerAdapter(
               quotes = quotes,
               isNameRevealed = isNameRevealed
           )
           viewPager.adapter = adapter
           
           ......
        // ViewPager2에 Adapter를 설정해준 다음에 해당 코드를 실행할 것
        
           viewPager.setCurrentItem(adapter.itemCount / 2, false)

    --> 무한 스와이프를 위해 ViewPager2의 시작 View를
    전체 index의 중간으로 보냄

    setCurrentItem()은 ViewPager2에 시작하는 View를 설정하는 메소드로

    첫번쨰 파라미터로 몇번째 View에서 시작할지 그 번호를 받고,
    두번째 파라미터로 smoothScroll의 여부를 Boolean으로 받는다.

    smoothScroll이 true이면, ViewPager2의 1번 View가 나온 뒤에
    해당 View의 번호까지 스크롤로 이동하게 됨ㅡ,
    따라서 false를 줘서 순간적으로 이동하도록 만듬

    즉, Adapter에서 설정한 전체 View의 수 ( 위의 코드대로 했다면 Int가 표현할 수 있는 최대의 수 )의
    절반의 번호이므로, 중간번호에서 시작할 것이며,
    해당 위치까지는 스크롤로 이동하는 것이 아닌 순간이동하듯이 바로 이동하여 시작할 것이다.


Firebase Remote Config ( 원격 구성 ) 소개

공식 문서 : https://firebase.google.com/docs/remote-config?authuser=0

앱 업데이트를 게시하지 않아도 하루 활성 사용자 수 제한 없이 무료로 앱의 동작과 모양을 변경할 수 있는 기능

--> 즉, 별도의 업데이트나 배포 없이 동작을 변경할 수 있음

Remote Config 기본 원리

  1. Remote Config Server측
    --> Firebase console에 RemoteConfig 값을 지정
    --> 지정한 RemoteCinfig값에 여러가지 조건들을 달 수 있음

    --> 가장 최상위 조건에 있는 값들 먼저 내려줌
    --> 매치를 했을 때 조건에 부합하지 않는다면, 서버 콘솔에 있는 default 값을 내려줌

  2. 앱측
    --> fetch하여 서버로부터 가져온 값들애 대해 먼저 서버로부터 가져온 값들이 activate됬을 경우 적용
    --> 그렇지 않다면 앱 상에 default로 정의된 값을 적용
    --> 그것도 아니라면 코드상에 값을 적용

Remote Config에서 주의할 부분 -> 정책 및 한도

  • 사용자의 승인이 필요한 업데이트의 경우, 정식 업데이트로 하는 것이 옳다, Remote Config를 이용하여 잠수함패치를 하는 것은 앱의 신뢰성을 해칠 수 있다.

  • Remote Config는 따로 암호화가 되어있지 않으므로 기밀 데이터를 다뤄서는 안된다.

  • 앱을 배포하려는 플랫폼에서 금지하는 부분에 대해,
    검수받을 때는 그 부분을 뺐다가 검수가 끝난 다음에 Remote Config를 사용하여 몰래 업데이트하여 넣는 등의 행위를 하지 말아야 한다.

  • Remot Config는 무제한 사용할 수 있는 것이 아니라, 매개변수와 조건등에 갯수 제한이 존재한다.
    자세한 내용은 아래와 같다.


Remote Config로 가능한 작업의 예시들

공식문서 : https://firebase.google.com/docs/remote-config/use-cases?authuser=0

비율 출시 메커니즘을 사용한 새 기능 출시

앱의 플랫폼 및 언어별 프로모션 배너 정의 -> 자주 사용하는 용도

--> 시즌 별로 앱에서 보여줄 이미지를 변경한다던가, 등에 사용한다.
( 즉, 특정 기간, 특정 시간이나 상황에 맞게 맞춤문구나 맞춤 이미지를 보여주기 위해 사용한다. --> 이런 부분을 업데이트 없이 할 수 있다는 것에 의의가 있음 )
( 광고와 마케팅의 영역까지 확대됨 )

제한된 테스트 그룹에서의 새 기능 테스트 --> 일종의 A B 테스트

--> 어떤 그룹을 제한시켜서 그 그룹 안에서만 테스트 해볼 수 있음

JSON을 사용한 앱 또는 게임의 복잡한 항목 구성

--> 여러가지 속성값들을 한꺼번에 수정할 수 있음

----------

Remote Config의 로딩 전략

공식문서 : https://firebase.google.com/docs/remote-config/loading?authuser=0

Remote Config는 위에서 설명했던 것처럼 서버에서 데이터를 fetch해오는 타이밍
이렇게 fetch해온 값을 실제로 activate하여 앱에서 보여주는 타이밍
다르기 때문에 Remote Config는 여러가지 전략을 사용하여 사용자에게 보여줄 수 있다.

전략 1 )) 로드 시 가져와 활성화

  • 앱이 시작할 때ㅡ, 바로 fetch후 activate를 하는 전략 ( fetchAndActivate() 호출 )

  • 앱이 실행되고 나서 바로 fetch가 적용되므로, UI가 눈에 띄에 변경될 수 있는 상황에는 적합하지 않음

    --> 예를 들어,
    앱이 실행되고 사용자가 메인 베너( 혹은 복잡한 UI )를 보고있는 상황에서 바로 fetch후 activate가 되어 베너의 내용이 변경된다면
    사용자입장에서 베너를 보고있는 중간에 베너가 변경되는 상황이 발생하므로 사용성이 매우 떨어지게 된다. ( 혹은 복잡한 UI라던가... )

전략 2 )) 로딩 화면 뒤에서 활성화 --> 가장 무난하고 대부분이 사용하고 있는 전략

  • 전략 1의 문제점을 해결하기 위해 로딩화면을 사용 !

  • fetch가 너무 오래 걸리는 경우를 대비하여 loading시간에 제한을 두는 편이 좋음

전략 3 )) 다음 시작 시 새 값 로드

  • 앱을 시작했을 때 바로 fetch를 하지만, activate는 하지 않음 -> 가지고만 있음
    그 다음에 앱을 실행할 때 바로 activate
    --> 이렇게 할 경우ㅡ, fetch를 기다리는 시작이 없기 때문에 바로 activate를 할 수 있음
    ( 좀더 원활하게 앱을 실행할 수 있음 )

  • 급하게 Remote Config해야 될 경우 어려움 -> 사용자가 앱을 2번 실행해야 하기 떄문

피해야 할 로딩 전략

  • 사용자가 현재 보고 있거나, 사용하고 있을 때 UI를 변경시키는 식의 전략

  • Remote Config를 통해 요청을 대량으로 할 경우 서버측에서 Block을 시킬 수도 있음
    -> 따라서 제한상황을 확인하고 최소한의 요청을 하는 것 이 바람직함

[ 제한 상황 ]


Remote Config 구성하기

  1. Firebase 프로젝트 만들기

  2. Firebase 프로젝트에 앱 프로젝트 등록 --> 방법은 앱 프로젝트 chapter9 (푸시 알림 수신기)에 있음
    -> https://velog.io/@odesay97/%EC%95%B1-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-09-1-%ED%91%B8%EC%8B%9C-%EC%95%8C%EB%A6%BC-%EC%88%98%EC%8B%A0%EA%B8%B0-firebase

  3. Remote Config에 대한 의존성을 가져와야 하므로
    앱 프로젝트의 app수준의 build.gradle파일에서 dependencies 에 firebase-bom아래에 해당 코드 추가 후 Sync

     firebase-bom은 파이어페이스의 기능들에 대해 프로젝트에 의존성을 줄 때,
     일일이 호환되는 버전을 확인하는 것이 번거로우므로 
     호환되는 버전을 bom에 모두 적어놓고 확인하는 것으로 호환성에 대한 문제를 해결하는 도구이다.
     ( 아마 2번에 Firebase프로젝트에 앱 프로젝트 등록할 때 해당 bom을 추가하라고 되어있을 것이다. )
    implementation 'com.google.firebase:firebase-config-ktx'

Remote Config 연동하기

Remote Config의 콘솔을 통해 Remote Config를 사용할 매개변수를 추가할 것임

--> 기본적으로 JSON형태로 데이터가 주고 받아짐

1. 매개변수 추가버튼 클릭

2. 매개변수 이름과 매개변수에 들어갈 Default Value를 추가

A -> 부분에 매개변수의 이름 -> 즉, KEY값을 입력한다.

B -> 부분에 매개변수의 값 -> 즉, Value를 입력한다.

  • Value의 경우 아래와 같이 주로 JSON의 형태나, 배열, 혹은 단일 값의 형태로 들어간다.

    [ 정상적으로 추가된 모습 ]

  • 이후 값을 추가하기 위해서는 아래와 같이 매개변수 추가 버튼을 클릭하면 된다.
    ( 아래과 같이 Boolean 값도 value로 가능하다. )

3. 변경사항 게시 버튼을 눌러 추가한 매개변수를 확정한다.

A는 이미 확정된 매개변수이며,
B는 아직 확정되지 않고 추가되어 있기만 한 매개변수이다. ( 따라서 화면에서 벗어나면 없어진다. )

변경사항 게시 버튼을 클릭하여 B를 확정해줘야만 B가 A의 상태로 바뀌어 매개변수가 적용되게 된다.

4. kotlin 코드측에서 매개변수의 값을 가져온다.

        val remoteConfig = Firebase.remoteConfig
        remoteConfig.setConfigSettingsAsync(
            remoteConfigSettings {
                minimumFetchIntervalInSeconds = 0


            }
        )
        
           
        remoteConfig.fetchAndActivate().addOnCompleteListener {
            if (it.isSuccessful) {
                val quotes = parseQuotesJson(remoteConfig.getString("quotes"))
                val isNameRevealed = remoteConfig.getBoolean("is_name_revealed")

                displayQuotesPager(quotes,isNameRevealed)

            }
        }    
        
        
        ......
        
         private fun parseQuotesJson(json: String): List<Quote> {
         
         ......
         
         }
  • val remoteConfig = Firebase.remoteConfig
    --> Firebase에서 remoteConfig를 가져오고 있음

  • minimumFetchIntervalInSeconds = 0
    --> remoteConfig의 fetch 시간 간격을 0으로 단축시키는 설정

    기본적으로 12시간 간격으로 정해져 있지만, 개발시에는 불편하기 때문에 이렇게 설정해놓고 개발한다.
    이렇게 설정하는 것으로 서버측에서 막기 전까지는 앱이 실행될 때마다 fetch 가능
    but 개발이 아니라 앱을 배포할 떄가 되면 해당 설정을 반드시 풀어줘야 한다.
    ( 왜냐하면 위의 12시간 원칙을 지키지 않을 경우, 서버측에서 블락해버림 )

  • remoteConfig.fetchAndActivate()
    --> fetch와 Activate를 동시에 실행한다.

  • remoteConfig.fetchAndActivate().addOnCompleteListener {}
    --> fetchAndActivate() 의 작업 완료에 대한 리스너 -> 작업에 실패했더라도 실행됨
    ( 성공에 대한 리스너가 아님ㅡ, 따라서 리스너 내부에서 if 문으로 성공 여부 확인 )

  • remoteConfig.getString("quotes")remoteConfig.getBoolean("is_name_revealed")
    --> "quotes"라는 파라미터명의 파라미터에서 데이터를 가져옴 ( 데이터의 형식은 String형 )
    --> "is_name_revealed"라는 파라미터명의 파라미터에서 데이터를 가져옴 ( 데이터 형식은 Boolean형 )

    Firebase RemoteConfig Console에는 다음과 같이 파라미터가 정의되어 있음

5. 필요하다면 데이터를 가공한다 ( 주로 JSON일 경우 )

바로 위에 quotes파라미터의 데이터를 보면 Json형태로 되어있다.
이런 데이터의 경우 가공하는 과정이 필요하다.


		......

  val quotes = parseQuotesJson(remoteConfig.getString("quotes"))
        
		......
                
                
  private fun parseQuotesJson(json: String): List<Quote> {
  
        // JSONArray 는 JSONObject로 이루어져있음
        val jsonArray = JSONArray(json)
        var jsonList = emptyList<JSONObject>()


        for (index in 0 until jsonArray.length()) {
            val jsonObject = jsonArray.getJSONObject(index)
            jsonObject?.let {
                jsonList = jsonList + it
            }
        }

        return jsonList.map{
            Quote(
                quote = it.getString("quote"),
                name = it.getString("name")
            )
        }
    }
    
    ......
    
    data class Quote(
    val quote: String,
    val name: String
)
  • val jsonArray = JSONArray(json)

    JSONArray()를 통해 JSON형태의 String타입의 객체를 데이터별로 Array로 나눠줄 수 있다.
    이때 생성된 JsonArray의 각 데이터는 JSONObject 객체로 이루어진다.

    JSONArray( " Json형태의 String타입 객체 " )

  • var jsonList = emptyList<JSONObject>()

    JSONArray는 List가 가진 다양한 메소드들을 사용할 수 없기 떄문에
    List로 옮겨 담기 위해 빈 리스트를 만들었다.
    ( 그리고 위에서 말했듯이 JSONArray는 JSONObject객체를 담고 있으므로
    해당 List도 JSONObect를 담도록 하였다. )

  • for (index in 0 until jsonArray.length()) { ... } --> for문 부분

    JSONArray의 내용물을 JSONObject를 담는 List로 옮겨담고 있다.

  • return jsonList.map{ ... } 부분

    map함수를 이용하여 리스트의 데이터 하나하나에 대해 람다함수를 실행하고
    그 리턴값들을 모은 리스트를 반환한다.

    --> jsonList내부의 JSONObject에 대해 getString("멤버변수명")을 통해 데이터를 받아오고 있다.

    JSONObject.getString("멤버변수명")

    --> 또한 그런 데이터들에 대해 data class로 선언된 Quote의 파라미터에 인자로 넣어서
    data class를 담는 리스트를 리턴하고 있다.

Json 데이터의 경우 대략 위와 같은 과정으로 가공하여 사용해주면 된다.


코드 소개

activity_main.xml


<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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">

    <androidx.viewpager2.widget.ViewPager2
        android:id="@+id/viewPager"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <ProgressBar
        android:id="@+id/progresBar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center" />
<!--    처음 앱이 실행되면 프로그레스 바가 돌다가 -> REMOTE CONFIG에 의한 데이터 fetch가 완료되는 시점에 visability를 GONE으로 설정하여 간단한 로딩창을 만듬-->

</FrameLayout>


MainActivity.kt


package com.example.aop_part3_chapter10

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.ProgressBar
import androidx.viewpager2.widget.ViewPager2
import com.google.firebase.ktx.Firebase
import com.google.firebase.remoteconfig.ktx.remoteConfig
import com.google.firebase.remoteconfig.ktx.remoteConfigSettings
import org.json.JSONArray
import org.json.JSONObject
import kotlin.math.absoluteValue

class MainActivity : AppCompatActivity() {

    private val viewPager: ViewPager2 by lazy {
        findViewById(R.id.viewPager)
    }

    private val progressBar: ProgressBar by lazy {
        findViewById(R.id.progresBar)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        initViews()
        initData()
    }

    private fun initViews() {

//        ViewPager2를 스와이프 했을 때의 효과에 변형을 주는 기능
//        일반적으로 크게 3가지 정도로 변형을 줘서 효과를 구현함 ㅡㅡㅡ, Translation( 가로 세로로 어떻게 이동하느냐에 대한 값 ), Scale( 얼마나 커지느냐 ), Alpha( 투명도 )
        viewPager.setPageTransformer { page, position ->

            when {
                position.absoluteValue >= 1.0F -> {
                    page.alpha = 0F
                }
                position == 0F -> {
                    page.alpha = 1F
                }
                else -> {
                    page.alpha = 1F - 2 * position.absoluteValue
                }
            }

        }
    }

    private fun initData() {
        val remoteConfig = Firebase.remoteConfig
        remoteConfig.setConfigSettingsAsync(
            remoteConfigSettings {
                minimumFetchIntervalInSeconds = 0
                // remoteConfig의 fetch간격을 0으로 단축시키는 설정
                // 기본적으로 12시간 간격으로 정해져 있지만, 개발시에는 불편하기 때문에 이렇게 설정해놓고 개발한다.
                //  이렇게 설정하는 것으로 서버측에서 막기 전까지는 앱이 실행될 때마다 fetch 가능
                // but 개발이 아니라 앱을 배포할 떄가 되면 해당 설정을 반드시 풀어줘야 한다.
                // ( 왜냐하면 위의 12시간 원칙을 지키지 않을 경우, 서버측에서 블락해버림 )

            }
        )

        // fetch와 Activate() 의 작업 완료에 대한 리스너 ( 성공에 대한 리스너가 아님ㅡ, 따라서 if 문으로 성공 여부 확인 )
        remoteConfig.fetchAndActivate().addOnCompleteListener {

            progressBar.visibility = View.GONE
            if (it.isSuccessful) {
                val quotes = parseQuotesJson(remoteConfig.getString("quotes"))
                val isNameRevealed = remoteConfig.getBoolean("is_name_revealed")

                displayQuotesPager(quotes, isNameRevealed)

            }
        }

    }

    private fun displayQuotesPager(quotes: List<Quote>, isNameRevealed: Boolean) {

        val adapter = QuotesPagerAdapter(
            quotes = quotes,
            isNameRevealed = isNameRevealed
        )

        viewPager.adapter = adapter
        viewPager.setCurrentItem(adapter.itemCount / 2, false)
        // ** 무한 스와이프를 위해 전체 index의 중간으로 보냄
        // smoothScroll이 true이면, 해당 위치까지 스크롤로 이동하게 됨 ㅡ, 따라서 false를 줘서 순간적으로 이동하도록 만듬

//        viewPager.adapter = QuotesPagerAdapter(
//            quotes = quotes,
//            isNameRevealed = isNameRevealed
//        )
    }

    // Json 데이터를 Quote데이터 클래스의 형태로 파싱하기 위한 메소드를 만듬
    private fun parseQuotesJson(json: String): List<Quote> {
        // JSONArray 는 JSONObject로 이루어져있음
        val jsonArray = JSONArray(json)
        var jsonList = emptyList<JSONObject>()


        for (index in 0 until jsonArray.length()) {
            val jsonObject = jsonArray.getJSONObject(index)
            jsonObject?.let {
                jsonList = jsonList + it
            }
        }

        return jsonList.map {
            Quote(
                quote = it.getString("quote"),
                name = it.getString("name")
            )
        }

    }
}

Quote.kt --> ViewPager2를 위한 데이터 클래스

package com.example.aop_part3_chapter10

data class Quote(
    val quote: String,
    val name: String
)

item_quote.xml -> ViewPager2를 위한 레이아웃

<?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">


    <TextView
        android:id="@+id/quoteTextView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginHorizontal="40dp"
        android:ellipsize="end"
        android:gravity="end|center_vertical"
        android:maxLines="6"
        android:textSize="30sp"
        app:layout_constraintBottom_toTopOf="@+id/nameTextView"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.4"
        app:layout_constraintVertical_chainStyle="packed"
        tools:text="나는 생각한다 고로 존재한다." />
    <!--    maxLine : 최대 6줄까지,-->
    <!--    ellipsize : maxLine을 넘어가면 설정한 부분을 ... 으로 하여 표시-->

    <TextView
        android:id="@+id/nameTextView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="15dp"
        android:ellipsize="end"
        android:gravity="end"
        android:maxLines="1"
        android:textSize="20sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="@id/quoteTextView"
        app:layout_constraintStart_toStartOf="@id/quoteTextView"
        app:layout_constraintTop_toBottomOf="@id/quoteTextView"
        tools:text="데카르트" />

</androidx.constraintlayout.widget.ConstraintLayout>

QuotesPagerAdapter.kt -> ViewPager2의 Adapter


package com.example.aop_part3_chapter10

import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.TextureView
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView

// 1. Adapter에 데이터 리스트( Quote는 데이터클래스로 정의했으며, 이것을 담는 리스트 )를 넣어서 ViewPager2에 Adapter를 세팅
class QuotesPagerAdapter(
    private val quotes: List<Quote>,
    private val isNameRevealed: Boolean

) : RecyclerView.Adapter<QuotesPagerAdapter.QuoteViewHolder>() {

    // 2. ViewHolder를 생성 -> 파라미터로 ViewPager2를 구성할 레이아웃을 Inflater로 가져와서 넣음
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
        QuoteViewHolder(
            LayoutInflater.from(parent.context)
                .inflate(R.layout.item_quote, parent, false)
        )


    // 6. 생성한 각각의 item_quote_View에 bind() 메소드를 이용하여 각 데이터 리스트의 데이터를 레이아웃에 적용시켜줌
    override fun onBindViewHolder(holder: QuoteViewHolder, position: Int) {

        // ** 무한 스와이프를 위해 어댑터를 속일 것임
        val actualPosition = position % quotes.size

        holder.bind(quotes[actualPosition], isNameRevealed)
        
//        holder.bind(quotes[position], isNameRevealed)

    }

// 5. 해당 메소드의 반환값( 데이터 리스트의 크기 )만큼의 item_quote_View( 내가 ViewPager2의 레이아웃으로 사용하기 위해 만든 xml파일 )를 ViewPager에 생성함
//    override fun getItemCount() = quotes.size


    override fun getItemCount() = Int.MAX_VALUE
    // ** 무한 스와이프를 위해 어댑터를 속일 것임


    // 3. onCreateViewHolder()에 의해 해당 ViewHoler 객체가 레이아웃을 파라미터로 받아서 생성됨
    class QuoteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        // 뷰홀더 클래스 정의 ㅡㅡ, (액티비티 ㅡ, xml)를 인자로 받아서 생성, Adapter에 들어온 리스트의 데이터를 액티비티에 할당하는 메소드를 가지고 있음

        // 4. 인자로 받은 레이아웃의 각 컴포넌트 주소값을 가져와서 세팅
        private val quoteTextView: TextView = itemView.findViewById(R.id.quoteTextView)
        private val nameTextView: TextView = itemView.findViewById(R.id.nameTextView)

        @SuppressLint("SetTextI18n")
        fun bind(quote: Quote, isNameRevealed: Boolean) {
            quoteTextView.text = "\"${quote.quote}\""

            if (isNameRevealed) {
                nameTextView.text = "-${quote.name}"
                nameTextView.visibility = View.VISIBLE
            } else {
                nameTextView.visibility = View.GONE
            }

        }

    }
}

좋은 웹페이지 즐겨찾기