[AAC] AAC예제 분석 – BasicSample

개요

구글에서는 기저구조인 Activity를 얇게 보일러플레이트만 하는 AppCompat 전략에서 벗어나 한 단계 더 추상화 레이어를 제공하기로 하고 공식적인 개발용 추상화 레이어로 AAC(Android Architecture Component)를 발표하게 됩니다.
여기에는 액티비티 생명주기와 무관한 퍼시스턴스의 기반이 되는 ViewModel, 마찬가지 개념에서 교환용 데이터와 비동기처리를 위한 LiveData, 적극적인 로컬데이터베이스의 사용을 촉진하는 ORM레벨의 Room을 기반으로 액티비티 그 자체를 코딩하지 않고 효과적인 라이프사이클의 공통요소처리를 위한 추상층을 만들 수 있는 LifeCycle이 포함되어있습니다.

물론 이 각각은 개념 상 연동하여 사용해야 할 필요는 없지만 API상 상호작용하기 쉽게 구성되어있고 전반적으로는 뷰와 뷰모델구조를 사용하여 바인딩을 비동기적으로 쉽게 할 수 있는 종합셋트를 제공하는 식입니다. 하지만 다른 MVVM 프레임웍에 대한 이해와 ORM이나 디자인패턴 등의 기저지식을 잘 알고 있다 하더라도, 이 프레임웍에 딱 맞는 구조를 제작하는데는 각 컴포넌트의 의도를 확실히 이해할 필요가 있습니다.

이에 따라 구글에서 공식적으로 제공하는 샘플 프로젝트가 어떤 식으로 작성되어있는지 분석하여 베스트프렉티스를 따라하면서 AAC의 실질적인 적용능력을 배양해보려 합니다.
AAC공식 홈페이지에서 android-architecture-components-master 샘플을 내려받으면 이 안에 다음과 같은 샘플들이 있습니다(코틀린은 제외했습니다)

  1. BasicSample
  2. BasicRxJavaSample
  3. GithubBrowserSample
  4. PagingSample
  5. PersistenceContentProviderSample
  6. PersistenceMigrationsSample

위에서 가장 기본적인 BasicSample을 분석해봅니다.

Persistence Sample

쨌든 1번 프로젝트를 안드로이드스튜디오로 열어서 빌드하면 다음과 같은 화면을 보게 됩니다.

앱을 만져보면 리스트와 상세 뷰가 있는 간단한 앱입니다. 실제 구동 시의 이름은 Persistence sample로 나옵니다.
이 앱의 코드를 차근차근 분석하면서 어떻게 AAC를 적용했는지 살펴보겠습니다.

매니패스트의 내용

짧으니 전문을 보면서 얘기하겠습니다.

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="com.example.android.persistence">

    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
    <uses-feature android:name="android.hardware.location.gps"/>

    <application
        android:name=".BasicApp"
        android:allowBackup="false"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name="com.example.android.persistence.ui.MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
    </application>

</manifest>
  1. 우선 권한요청을 보면 위치관련된 권한을 전부 요청하고 있습니다. FINE_LOCATION은 일반 위치접근이고 COARSE는 기지국이용 위치접근인데 둘다 요구하고 feature로도 gps를 원한다고 써두었습니다. 이 앱에서는 겉만 봐서는 전혀 안쓰는거 같은데 ^^; 나중에 코드를 보면서 확인해보죠.
  2. 액티비티는 하나만 정의되어있고 런쳐용입니다. 따라서 위의 스크린 샷에 나왔던 두 개의 화면은 아마도 프레그먼트들이겠죠.
  3. Application을 기본걸 쓰지 않고 BasicApp이라고 만들어서 쓰고 있습니다. 나중에 뷰모델 보면서 같이 보겠습니다.

이상입니다. 결과적으로 앱의 구동은 MainActivity로 부터 시작되니 거기로 가보죠.

MainActivity

특이점은 크게 없습니다. 짜피 노티도 없고 어퍼니티에서 불릴 것도 아니라서 인텐트 대응은 전혀 하지 않는 간단한 액티비티입니다. 우선 onCreate를 살펴보죠.

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

    if (savedInstanceState == null) {
        ProductListFragment fragment = new ProductListFragment();
        getSupportFragmentManager().beginTransaction()
                .add(R.id.fragment_container, fragment, ProductListFragment.TAG).commit();
    }
}

메인용 xml을 로딩한 뒤 번들 유무로 프레그먼트매니져 초기화를 해주고 있습니다. 흐름 상 ProductListFragment가 제품리스트를 나타내는 뷰가 될 것이고 xml에는 프레그먼트가 들어간 fragment_container에 해당되는 프레임레이아웃 하나만 들어가 있을 것입니다. 기타 타이틀이나 액션바의 설정은 전혀 건드리고 있지 않습니다.

그 외에 show라는 상세내용을 보여주는 메소드도 제공하고 있습니다.

public void show(Product product) {
    ProductFragment productFragment = ProductFragment.forProduct(product.getId());
    getSupportFragmentManager().beginTransaction().addToBackStack("product")
        .replace(R.id.fragment_container,productFragment, null).commit();
}

역시 특기할 사항은 전혀 없습니다. 인자로 전달받은 Product의 id를 인자로 팩토리에게 보내 프레그먼트를 얻은 뒤 백스택에 등록하면서 컨테이너의 프래그먼트를 교체하고 있습니다.
여기까지는 매우 일반적인 프레그먼트 컨트롤입니다. 이제 리스트용 프래그먼트 클래스인 ProductListFragement에 가볼 차례네요.

ProductListFragement

이 클래스에서는 생명주기 중 두 개만 커버합니다. 초기화를 onCreateView에서 하고 실제 뷰의 업데이트를 onActivityCreated에서 합니다. 차근차근 코드를 보죠.

public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
        @Nullable Bundle savedInstanceState) {
    mBinding = DataBindingUtil.inflate(inflater, R.layout.list_fragment, container, false);
    mProductAdapter = new ProductAdapter(mProductClickCallback);
    mBinding.productsList.setAdapter(mProductAdapter);
    return mBinding.getRoot();
}

우선 데이터바인딩을 이용해 list_fragment뷰를 인플레이트합니다. 데이터바인딩에 대해 익숙하지 않은 분들은 아래 링크를 참고하세요.
https://developer.android.com/topic/libraries/data-binding/index.html

잠시 list_fragment의 데이터 선언 부분을 보면

<data>
  <variable name="isLoading" type="boolean" />
</data>

이렇게 기본 변수 하나 선언되어있고 실제 뷰에서는 이 변수에 따라 TextView와 RecyclerView가 토글되는 장치를 해뒀습니다(고작 이 정도를 하려고 데이터바인딩을 쓰는 것도 의외네요! 딴 분들도 이 정도에 이거 쓰나요?) 토글 기법은 다음과 같습니다.

<TextView .. app:visibleGone="@{isLoading}"/>
<RecyclerView ..  app:visibleGone="@{!isLoading}"/>

음음 잠시 흥분을 가라앉히고 계속 해보죠. 이어서 리사이클러뷰에 어뎁터 설정을 한 뒤 getRoot()를 반환합니다. 어뎁터에 대한 내용은 중요하므로 일단 라이프사이클을 정리한 뒤 다시 보겠습니다. 이어서 onActivityCreated의 내용을 보죠.

public void onActivityCreated(@Nullable Bundle savedInstanceState) {
    super.onActivityCreated(savedInstanceState);
    final ProductListViewModel viewModel =
            ViewModelProviders.of(this).get(ProductListViewModel.class);
    subscribeUi(viewModel);
}
private void subscribeUi(ProductListViewModel viewModel) {
    viewModel.getProducts().observe(this, new Observer<List<ProductEntity>>() {
        @Override
        public void onChanged(@Nullable List<ProductEntity> myProducts) {
            if (myProducts != null) {
                mBinding.setIsLoading(false);
                mProductAdapter.setProductList(myProducts);
            } else {
                mBinding.setIsLoading(true);
            }
            mBinding.executePendingBindings();
        }
    });
}

이 시점에 벌써 뷰모델을 팩토리로부터 받아오고 즉시 구독을 시작합니다. 이어지는 구독메소드를 보면 옵져버를 생성하는데 onChange에서 로딩여부를 ProductEntity리스트가 왔냐 안왔냐로 나눠서 처리하고 있습니다.

마지막으로 이 안에는 리사이클러뷰의 어뎁터에 넘겨줄 click리스너가 있습니다. 이 리스너는 리스트에서 각 항목을 클릭을 하면 어떻게 되는지를 기술하고 있는데 그 코드를 잠시 살펴보죠.

public void onClick(Product product) {
    if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) {
        ((MainActivity) getActivity()).show(product);
    }
}

현재 프레그먼트의 라이프사이클이 STARTED이후인 경우만 반응하게 해두고, 액티비티의 show를 호출하게 했습니다. 간단하죠. 이어서 리사이클러뷰의 어뎁터를 살펴본 뒤 궁금한 뷰모델을 살펴볼테니 너무 조바심 내지 마세요 ^^;

ProductAdapter

우선 리사이클러뷰에 익숙하지 않으신 분들은 다음의 문서를 보세요(기존 어뎁터 기반보다 중간에 개입할 수 있는 뷰홀더가 포함된 구조로 개선된 게 핵심입니다)

https://developer.android.com/reference/android/support/v7/widget/RecyclerView.html

우선 onCreateViewHolder를 살펴보면 데이터바인딩을 이용해 인플레이트한 뒤 앞 서 생성자에서 넘겨받았던 ProductClickCallback콜백을 그대로 뷰에 연결해줍니다. 또한 뷰홀더에는 데이터바인딩 객체를 넘겨줌으로서 간단히 뷰를 갱신할 수 있게 합니다(이거 참 아름답네요 ^^)

onBindViewHolder시점에서는 mProductList속성에 잡혀있는 Product리스트로부터 데이터를 얻어 넣어줍니다.

그럼 이제 남은 문제는 ProductAdapter에 실제 Product리스트를 넣어주는건데 이는 setProductList라는 메소드로 대응하고 있습니다. 이 메소드는 간단합니다.

void setProductList(final List<? extends Product> productList) {
    if (mProductList == null) {
        mProductList = productList;
        notifyItemRangeInserted(0, productList.size());
    } else {
        DiffUtil.DiffResult result = DiffUtil.calculateDiff(new DiffUtil.Callback() {
          ...
        });
        mProductList = productList;
        result.dispatchUpdatesTo(this);
    }
}

리스트가 최초로 들어오는 부분이야 그냥 업데이트하고 최초 셋팅된 후에 업데이트된 경우는 DiffUtil에게 차이점 계산시켜서 디스패치 시키고 있습니다. DiffUtil은 리사이클러뷰와 마찬가지로 v7에 포함된 유틸로 익숙하지 않은 분들은 아래 링크를 참고하세요.
https://developer.android.com/reference/android/support/v7/util/DiffUtil.html

나머지는 큰 내용이 없습니다. 아쉬우니 살짝 뷰홀더 xml의 바인딩 부분을 볼까요(product_item.xml)

<data>
    <variable name="product"
              type="com.example.android.persistence.model.Product"/>
    <variable name="callback"
              type="com.example.android.persistence.ui.ProductClickCallback"/>
</data>

모델인 Product와 클릭을 처리할 콜백을 정적으로 바인딩해뒀습니다.

<android.support.v7.widget.CardView
    ...
    android:onClick="@{()->callback.onClick(product)}">

카드 뷰의 onClick에는 직접 람다로 연결해뒀네요(전부터 궁금한건데 xml에서의 데이터바인딩 람다표현식은 옛날 sdk에서도 되는건가…) 어뎁터를 다 봤으니 이제 대망의 뷰모델로 가겠습니다.

ProductListViewModel

실제 데이터를 공급할 뿐만 아니라 그 데이터의 변경사항을 통지하는 뷰모델입니다. 뷰모델은 액티비티나 프레그먼트의 생명주기와 무관하게 앱 종료 시까지 유지되는 컨텍스트입니다.
간단한 설명은 아래 유튜브를 참고하시면 됩니다(5분도 안되는 짧은!)

[youtube https://www.youtube.com/watch?v=c9-057jC1ZA&t=9s]

암튼 여기 코드를 보면 MutableLiveData나 그냥 LiveData를 사용하지 않고 MediatorLiveData를 사용하고 있습니다. 이 세 개의 차이점은 아래 유튜브에 잘 설명되어 있습니다.

[youtube https://www.youtube.com/watch?v=jCw5ib0r9wg]

MediatorLiveData는 여러 LiveData를 통합하여 수신하는 녀석인데 데이터베이스로부터 데이터가 들어오는 것을 수신하려고 이걸 사용했습니다. 결국 MutableLiveData는 직접 컨트롤할 값이 아닌 이상 안쓰지 않을까 싶네요. 여튼 생성자를 볼까요.

private final MediatorLiveData<List<ProductEntity>> mObservableProducts;

public ProductListViewModel(Application application) {
    super(application);
    mObservableProducts = new MediatorLiveData<>();
    mObservableProducts.setValue(null);
    LiveData<List<ProductEntity>> products = ((BasicApp) application).getRepository()
            .getProducts();
    mObservableProducts.addSource(products, mObservableProducts::setValue);
}

우선 속성에 MediatorLiveData객체를 만들어 넣고 최초 데이터를 null로 초기화합니다.
그 후 Application을 BasicApp으로 변환하여 레포를 얻고 레포에서 다시 Products를 얻어 이를 소스로 등록해줍니다. 이후 Product쪽의 데이터가 공급되면 이쪽도 노티를 서브스크립터들에게 보내줄 것입니다. 매니패스트에서 Application을 BasicApp으로 지정해뒀으므로 생성자에 들어온 Application을 BasicApp으로 형변환하여 사용하고 있습니다.
나머지 공급 구조는 BasicApp을 살펴봐야겠죠.

BasicApp

앱 생존기간 내내 AppExecutors의 인스턴스를 유지하면서 AppDatabase에 공급하고 나머지는 개별 객체에게 메소드의 책임을 전가하고 있습니다.
AppExecutors는 세 개의 독립적인 워커쓰레드풀을 만들어 카테고리별로 Runnable을 주면 알아서 잘 처리하도록 해 놓은 편의클래스입니다. 특이 사항은 없고 일반적인 내용들이네요.

레포지토리를 얻는 getRepository메소드는 DataRepository의 팩토리를 부르고 있습니다. 이때 인자로 AppDatabase를 넘기는데 우선 DataRepository부터 살펴보겠습니다.

DataRepository

싱글톤으로 운영되고 MediatorLiveData를 생성합니다. 결국 앞에서 다뤘던 ProductListViewModel에서 구독하는 대상이 이 클래스의 mObservableProducts 속성인 셈입니다.
여기까지 정리해보면 라이브데이터를 소스로 하는 라이브데이터를 수신하는게 ProducListFragment가 되고 onChanged에 반응하여 리사이클러의 어뎁터의 ProductList를 갱신해주는 구조인 셈입니다. 이제 남은 건 DataRepository가 어떻게 갱신되냐는 건데 그건 바로 AppDatabase를 소스로 등록하여 그 변화에 따라 갱신되는 것입니다. 즉

AppDatabase → DataRepository → ProductListViewModel → ProducListFragment → mProductAdapter

위의 순서로 전파되는거죠. 중간중간 역할에 따라 부여하고 싶은 책임이 있다는 것도 한 편으로는 이해가 되지만 미디에이터를 남발하면서 이렇게까지 해야하나 싶은 생각도 드네요.
특히 이 DataRepository는 순수한 유틸리티 역할만 하고 있는 POJO입니다. 또한 구독하는 대상도 사실상 ProductListFragement뿐이므로 직접 AppDatabase를 구독해도 무방했을거 같은데 말이죠.
암튼 이제 진짜 데이터의 원천인 AppDatabase로 가보겠습니다.

AppDatabase이하 Room ORM

이 클래스는 RoomDatabase를 상속받은 ORM객체입니다. Room의 개념은 아래 유튜브를 참고하세요.

[youtube https://www.youtube.com/watch?v=H7I3zs-L-1w]

Entity

AppDatabase클래스를 시작으로 그 밑에 있는 관련 클래스는 전부 Room의 ORM을 구축하는 클래스입니다. 해서 사실 역으로 짚어보는게 더 쉬울 수 있습니다.
가장 의존성이 없는 클래스는 엔티티 클래스들입니다. 이들은 do/entity안에 있는 ProductEntity와 CommentEntity입니다. 이들은 model에 정의된 인터페이스를 따르는데 그렇다고 완전히 ProductEntity를 은닉할 수 있는 것도 아니고 프래그먼트 짜피 구독 시점에 들어오는 건 List가 될거라 고작 어뎁터의 뷰홀더에게 세터 감추자고 이런 짓을 해야하나 싶습니다. 게다가 떡하니 그 이름으로 인터페이스들을 model이라 만들어줬는데 영 동의하기 힘든 추상화네요.

여튼 엔티티는 애노태이션으로 정의하게 되어있습니다. 중요한 Room의 엔티티 애노태이션은 두 가지로 tableName과 PrimaryKey입니다.

@Entity(tableName = "products")
public class ProductEntity implements Product {
    @PrimaryKey
    private int id;
    private String name;
    private String description;
    private int price;

필드의 이름이 정확히 테이블의 필드명과 일치해야 하고 빈 생성자를 반드시 갖고 있어야 합니다. 만약 필드이름이 다르다면 @ColumeInfo(name = “aaaa”) 처럼 명시해줘야 합니다.
이러한 엔티티를 1차로 소비하는 층은 Dao입니다.

DAO

DAO 역시 Room의 애노태이션으로 정의되는 인터페이스입니다. 클래스가 아니라 인터페이스만 정의하면 구상DAO는 알아서 Room이 만들어줍니다. 중요한 애노태이션은 Dao와 Query입니다.

@Dao
public interface ProductDao {
    @Query("SELECT * FROM products")
    LiveData<List<ProductEntity>> loadAllProducts();

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    void insertAll(List<ProductEntity> products);

    @Query("select * from products where id = :productId")
    LiveData<ProductEntity> loadProduct(int productId);

    @Query("select * from products where id = :productId")
    ProductEntity loadProductSync(int productId);
}

select문의 :인자명 규칙은 기본적인거라 큰 무리없습니다만 Insert쿼리는 좀 이해하기 어렵죠. 기본적으로 Insert 애노태이션은 무조건 해당 엔티티 타입을 인자로 받습니다.
또한 해당 엔티티타입의 리스트나 배열도 허용합니다. 그럼 위의 onConflict는? 같은 키에 대한 충돌 상황에 대한 처리 방법을 지정한 것입니다. 이는 SQLLite의 설정을 그대로 반영한 것으로 이 정책을 잘 모르시는 분들은 아래 링크를 참고하세요.
https://sqlite.org/lang_conflict.html

DateConverter

Room은 엔티티를 제외한 직접적인 객체참조를 쿼리에 적용하는 것을 금지하고 있습니다. 왜 금지하는지에 대한 자세한 내용이 아래 있습니다.
https://developer.android.com/training/data-storage/room/referencing-data.html#understand-no-object-references

여튼 그렇기 때문에 TypeConverter가 존재합니다. 이 컨버터를 Room의 DB에 등록하면 이후 모든 쿼리에 등장하는 객체형은 컨버터를 통해 기본형으로 번역됩니다.
이 프로젝트에서는 간단히 날짜를 timestamp로 바꾸는 컨버터를 구현했습니다. 정적 메소드에 @TypeConverter라는 애노태이션을 붙여주면 됩니다. 기본 재료가 다 모였으니 이를 묶을 데이터베이스를 살펴볼 차례입니다.

AppDatabase

하나의 데이터베이스를 추상화하는 RoomDatabase를 상속하여 @Database애노태이션을 붙여주면 만들어집니다. 바로 직전에 정의했던 TypeConverter도 물론 애노태이션으로 지정해줄 수 있습니다.

@Database(entities = {ProductEntity.class, CommentEntity.class}, version = 1)
@TypeConverters(DateConverter.class)
public abstract class AppDatabase extends RoomDatabase {

Room이 제공하는 databaseBuilder로 만들어지는 싱글톤 인스턴스는 생성완료시 호출되는 콜백에서 가짜 데이터생성기에게 데이터를 받아서 데이터를 삽입하고 있습니다. 이미 다수의 데이터가 생성되어 List나 List가 만들어져 있습니다. 그것을 그대로 insertData에 넣어준 뒤 후처리를 합니다. 우선 insertData메소드를 보죠.

private static void insertData(final AppDatabase database, final List<ProductEntity> products,
        final List<CommentEntity> comments) {
    database.runInTransaction(() -> {
        database.productDao().insertAll(products);
        database.commentDao().insertAll(comments);
    });
}

근데 productDao()은 위에 abstract로 선언되어있습니다.

public abstract ProductDao productDao();
public abstract CommentDao commentDao();

이 메소드로 호출되는 Dao의 매핑은 빌더 생성시 다음의 코드로부터 Room이 리플렉션해서 서브클래싱으로 만들어 넣어준 것입니다.

Room.databaseBuilder(appContext, AppDatabase.class, DATABASE_NAME)

이 시점에 클래스를 넘겨 빌더가 abstract로 지정된 Dao를 활성화시켜줍니다. 결국 이 구조에서는 데이터베이스를 빌드하기 전에 모든 쿼리에 대한 모든 Dao를 확정지어두어야합니다.
이렇게 뒷처리가 다 마무리되는 시점에 로딩완료를 통지하기 위해 MutableLiveData mIsDatabaseCreated로 선언한 불린 속성을 true로 바꿔줍니다.
이상이 전체의 코드 분석 결과입니다.

구조에 대한 종합정리

전체적으로 지금까지 분석한 개별 클래스들 간의 의존성을 중심으로 살펴보면

  1. 액티비티가 프래그먼트 매니져로 리스트와 상세보기 화면 간의 전환을 담당합니다.
  2. 각 프래그먼트는 자신과 관련된 뷰모델을 만들어 구독합니다.
  3. 리스트화면용 뷰모델은 데이터베이스Dao를 중계하는 리스트용 데이터 값을 전달해 줍니다.
  4. 뷰모델은 라이브데이터를 레포지토리의 라이브데이터로 수신해 다시 프래그먼트쪽에 전달합니다.
  5. 레포지토리는 데이터베이스의 Dao를 수신해 뷰모델에 전달하는 역할을 수행합니다.
  6. 결국 이 단계에서 위에 한참 분석하던 데이터베이스, Dao, 엔티티가 차례로 소비됩니다.

폴더 구조로 보면 루트 폴더에 Application과 Repository를 두고 있습니다. 이 둘은 일종의 전역객체죠. 특히 생명주기가 Application 레벨입니다.

ui폴더 안에는 액티비티, 프레그먼트, 리사이클러뷰용 어뎁터와 리스너들이 들어있습니다. 머 이걸 ui로 부르는건 이해할 만하지만, 이들을 연결해주는 기능이 라우터같은거 없이 개별로 작성되어있습니다. 대부분의 ui는 라이브데이터를 수신하고 데이터가 들어오면 데이터바인딩을 통해 화면을 업데이트하는 식입니다. 하지만 뷰의 내용을 바꾸면 뷰모델쪽에 전달하는 바인딩 매커니즘은 딱히 없습니다. 다른 예제에서 봐야겠죠.

model폴더는 테이블별 엔티티를 다시 감싼 인터페이스를 제공하는데 이건 거의 의미를 못찾겠습니다. 그래봐야 고작 리사이클러뷰홀더에게 엔티티를 감추는 정도인데, 이미 뷰모델에서 어뎁터까지 전부 엔티티를 알고 있는 상황에서 이게 머 대수라고..=.=;

마지막으로 db폴더에는 데이터베이스, 엔티티, Dao가 들어있습니다.

결론

쓸데없는 추상화도 있고 뷰 단의 내용을 모델 쪽에 전달하는 내용도 등장하지 않아 아쉬움이 많습니다만 뷰모델, 라이브데이터, 룸이 전부 등장하는 예제로 기존 데이터바인딩과 함께 어떻게 사용해야하는지 보여주는 기본 예제였습니다. 각각을 사용하는 방법이나 보다 구체화된 미디에이터라이브데이터의 사용예제로 괜찮네요.

%d 블로거가 이것을 좋아합니다: