본문

180322(목) - Android Architecture Component (Guide to App Architecture)

Android Architecture Component 


Guide to App Architecture


Common problems faced by app developers

- desktop은 하나의 진입점인 숏컷과 싱글 monolithic process이지만 android는 이와 다르게 더 복잡한 구조이다.

- 일반적인 android app은 여러가지 android component들로 구성된다.

- 이러한 이유로 android app은 app간 이동, switching flows and tasks에 훨씬 더 flexible해야 한다.

- app component는 일시적이며 lifecycle를 제어 불가능하기 때문에 app component에 app data or state를 저장하면 안된다.

- 또한 app component가 서로 depend on 되어서는 안된다.


Common architectural principles

1. UI or operating system interactions를 제외한 코드는 Activity, Fragment 클래스에 있으면 안된다.

- 모든 코드를 Activity or Fragment에 작성하는것은 멍청한 짓이다.

- 이러한 코드들을 분리한다면 lifecycle관련 많은 문제를 회피가능하다.

- 또한 Android OS는 메모리 부족, 사용자 상호작용 등의 요인에 의해 언제든 파괴 가능하므로 이 역시 회피 가능


2. UI는 model에서 drive해야한다. 정확히는 persistent model !!!

- OS가 메모리 확보를 핑계로 app을 destroys하고 network connection flaky or not connected 이더라도 app이 계속 running중이라면 user가 data를 잃지 않는다.

- model은 app의 data handling을 담당하는 component

- app View and Component과 독립적이므로 lifecycle문제로부터 격리된다.

- 또한 UI code가 간결해져 managing하기에 수월하다.

- testing 하기에도 유리하여 app consistent를 유지 가능하다.


Recommended app architecture

ViewModel

- specific UI component에 data 제공

- data handling의 business part와 communication

- View에 대해서 알지 못함

- configuration changes에 대해서 영향 받지 않는다.


public class UserProfileViewModel extends ViewModel {
   
private String userId;
   
private User user;

   
public void init(String userId) {
       
this.userId = userId;
   
}
   
public User getUser() {
       
return user;
   
}
}
public class UserProfileFragment extends Fragment {
   
private static final String UID_KEY = "uid";
   
private UserProfileViewModel viewModel;

   
@Override
   
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
       
super.onActivityCreated(savedInstanceState);
       
String userId = getArguments().getString(UID_KEY);
        viewModel
= ViewModelProviders.of(this).get(UserProfileViewModel.class);
        viewModel
.init(userId);
   
}

   
@Override
   
public View onCreateView(LayoutInflater inflater,
               
@Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
       
return inflater.inflate(R.layout.user_profile, container, false);
   
}
}


LiveData

- Observable data holder

- explicit and rigid dependency를 만들지 않고 change object를 observe 가능하다.

- lifecycle state를 respects

- object leaking and memory leak 방지


※ Reactive

- 기존에 RxJava를 사용하고 있었다면 LiveData 대신에 사용 가능하다. 

- 그러나, LifecycleOwner가 stopped되면 stream도 stopped 시켜야 한다.

- android.arch.lifecycle:reactivestreams 를 사용하면 LiveData상에서도 RxJava2를 사용 가능하다.


public class UserProfileViewModel extends ViewModel {
   
...
   
private User user;
   
private LiveData<User> user;
   
public LiveData<User> getUser() {
       
return user;
   
}
}
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
   
super.onActivityCreated(savedInstanceState);
    viewModel
.getUser().observe(this, user -> {
     
// update UI
   
});
}


- data가 updated 되면, onChanged callback이 invoked and UI refreshed

- view가 active상태 (onStart() and not onStop())가 아니면 callback을 invoke하지 않는다.

- onDestroy()를 수신하면 LiveData는 observer를 자동으로 제거한다.

- ViewModel은 configulation이 변경되면 자동으로 restored 하므로 new life로 변경되면 ViewModel의 동일한 instance를 받고 current data가 callback으로 호출된다.

- View보다 더 오래 outlive할 수 있다. (The lifecycle of a ViewModel.)


Fetching data

public interface Webservice {
   
/**
     * @GET declares an HTTP GET request
     * @Path("user") annotation on the userId parameter marks it as a
     * replacement for the {user} placeholder in the @GET path
     */

   
@GET("/users/{user}")
   
Call<User> getUser(@Path("user") String userId);
}

- 단순하게 ViewModel에서 Webservice 데이터를 직접 가져온다면, 앱이 커져감에 따라서 관리가 어려울 수 있다.

- 또한 ViewModel에서 여러가지 일을 맡게 되므로 separation of concerns principal에 위배

- ViewModel 은 Activity, Fragment lifecycle과 연관되어있으므로 data 손실의 위험이 있다.

- ViewModel delegate new Repository module


Repository

- handling data operations

- data를 getting 할 위치와 update시 call 할 API를 알고 있다.

- 서로다른 data source간 중개자 역할

- ViewModel은 WebService가 data fetch를 한다는것을 모르고, 이는 swap이 가능하다는 얘기다.

public class UserRepository {
   
private Webservice webservice;
   
// ...
   
public LiveData<User> getUser(int userId) {
       
// This is not an optimal implementation, we'll fix it below
       
final MutableLiveData<User> data = new MutableLiveData<>();
        webservice
.getUser(userId).enqueue(new Callback<User>() {
           
@Override
           
public void onResponse(Call<User> call, Response<User> response) {
               
// error case is left out for brevity
                data
.setValue(response.body());
           
}
       
});
       
return data;
   
}
}


Managing dependencies between component

- 위 코드는 간단하지만 UserRepository가 Webservice instance를 만들어야한다. 즉 종속성이 생겨버린다.

- code가 complicate and duplicate 된다.

- 게다가 종속성은 필요한 클래스가 많아지면 더 늘어나게 된다.

-> 그래서 Dagger2 가 필요한 거시닷!


Connecting ViewModel and the repository

public class UserProfileViewModel extends ViewModel {
   
private LiveData<User> user;
   
private UserRepository userRepo;

   
@Inject // UserRepository parameter is provided by Dagger 2
   
public UserProfileViewModel(UserRepository userRepo) {
       
this.userRepo = userRepo;
   
}

   
public void init(String userId) {
       
if (this.user != null) {
           
// ViewModel is created per Fragment so
           
// we know the userId won't change
           
return;
       
}
        user
= userRepo.getUser(userId);
   
}

   
public LiveData<User> getUser() {
       
return this.user;
   
}
}

Caching data

- 위 소스는 web service에 대한 abstractions는 성공했지만, 하나의 data source에 의존하여 기능적이지는 못함

- user가 Fragment를 나갔다가 다시 들어오면 app은 re-fetches the data

1. 네트워크 낭비

2. user가 새로운 query가 완료되길 기다려야 한다.


- 위와같은 문제를 해결하기 위해서 UserRepository쪽에 local memory cache를 만든다.

@Singleton  // informs Dagger that this class should be constructed once
public class UserRepository {
   
private Webservice webservice;
   
// simple in memory cache, details omitted for brevity
   
private UserCache userCache;
   
public LiveData<User> getUser(String userId) {
       
LiveData<User> cached = userCache.get(userId);
       
if (cached != null) {
           
return cached;
       
}

       
final MutableLiveData<User> data = new MutableLiveData<>();
        userCache
.put(userId, data);
       
// this is still suboptimal but better than before.
       
// a complete implementation must also handle the error cases.
        webservice
.getUser(userId).enqueue(new Callback<User>() {
           
@Override
           
public void onResponse(Call<User> call, Response<User> response) {
                data
.setValue(response.body());
           
}
       
});
       
return data;
   
}
}

Persisting Data

- app의 process를 날리면 data를 re-fetch하게 되는데 이도 마찬가지로 사용자에게 나쁜경험을 주며 같은 data를 로딩하기 때문에 낭비다.

- 이를 올바르게 처리하는 방법은 persistent model을 사용하는 것이다.


Room

- 최소한의 boilerplate로 local data를 제공하는 object mapping library

- compile time에 schema에 대한 query의 유효성을 검사하므로 broken SQL queries로 인한 runtime error대신 compile time error가 발생한다.

- room은 SQL table 및 queries로 작업하는 기본 implementation를 abstract한다.

- database data changes를 감지하고 이를 LiveData에 exposing 가능하다.

- main thread storage accessing 과 같은 common issues를 해결하는 thread constraints를 explicitly defines 가능


- local schema define 

@Entity
class User {
 
@PrimaryKey
 
private int id;
 
private String name;
 
private String lastName;
 
// getters and setters for fields
}
@Database(entities = {User.class}, version = 1)
public abstract class MyDatabase extends RoomDatabase {
}
@Dao
public interface UserDao {
   
@Insert(onConflict = REPLACE)
   
void save(User user);
   
@Query("SELECT * FROM user WHERE id = :userId")
   
LiveData<User> load(String userId);
}
@Database(entities = {User.class}, version = 1)
public abstract class MyDatabase extends RoomDatabase {
   
public abstract UserDao userDao();
}

※ Room은 table modifications를 기반으로 유효성 체크를 하므로 false positive notifications를 할 가능성도 있다.


@Singleton
public class UserRepository {
   
private final Webservice webservice;
   
private final UserDao userDao;
   
private final Executor executor;

   
@Inject
   
public UserRepository(Webservice webservice, UserDao userDao, Executor executor) {
       
this.webservice = webservice;
       
this.userDao = userDao;
       
this.executor = executor;
   
}

   
public LiveData<User> getUser(String userId) {
        refreshUser
(userId);
       
// return a LiveData directly from the database.
       
return userDao.load(userId);
   
}

   
private void refreshUser(final String userId) {
        executor
.execute(() -> {
           
// running in a background thread
           
// check if user was fetched recently
           
boolean userExists = userDao.hasUser(FRESH_TIMEOUT);
           
if (!userExists) {
               
// refresh the data
               
Response response = webservice.getUser(userId).execute();
               
// TODO check for error etc.
               
// Update the database.The LiveData will automatically refresh so
               
// we don't need to do anything else here besides updating the database
                userDao
.save(response.body());
           
}
       
});
   
}
}

- UserRepository의 from data 가 변경되더라도 UserProfileViewModel and UserProfileFragment는 변경할 필요가 없다.

- 따라서 fake UserRepository, UserProfileViewModel 을 제공 가능하기 때문에 test에도 좋음

- UI action과 data update action은 분리해야한다.

common solution

1. network 작업 status 가 포함된 LiveData 반환

2. user의 refresh status를 return 하는 Repository class에 public function 제공. UI에 network status를 보여주려는 경우 유리하다. (pull-to-refresh)


Single source of truth

- 일반적으로 REST API의 각기다른 endpoints는 같은 data를 return한다.

- UserRepository가 Webservice response를 그대로 return하는 경우, server side에서 request때마다 data가 변경될 수 있으므로 inconsistent data가 발생할 수 있다.

- 이와같은 이유로 UserRepository 에서 web service callback이 database에 data를 저장하는 것이다!

- database를 변경하면 active LiveData objects에 대한 callback이 trigger

- 여기서 database는 single source of truth로 사용되며 앱은 repository를 통해서 access한다.

- DIsk cache 사용 여부와 관계없이 data source를 single source of truth로 사용하기를 권장한다.


Testing

- User Interface & Interactions

Android UI Instrumentation test.

- UI test에는 Espresso를 쓰는것이 좋다.


- ViewModel

- JUnit test


- UserRepository

- JUnit test


- UserDao

- instrumentation test


- Webservice

MockWebServer


- Testing Artifacts

- background thread를 제어하기 위한 maven artifact 제공 -> android.arch.core:core-testing 에 2가지 JUnit rule이 있다.

1. InstantTaskExecutorRule

calling thread에서 bacground operation 바로 실행

2. CouningTaskExcutorRule

instrumentation test에서 background operations를 기다리는 등 waiting에 쓰임


The final architecture

Guiding principles

- 다음 사항은 의무는 아니지만 testable and maintainable in the long run 하기위한 recommendations이다.

1. manifest에 정의한 entry points(activity, services, broadcast, receivers etc...) 는 data source가 아니다.

2. app의 모듈간 responsibility의 boundaries를 정의하는데 merciless할 것.

ex) network data loading code를 여러 package or class에 분산시키지 말자

- 마찬가지로 data caching and data binding과 같은 관련없는 responsibilities를 같은 class에 넣지말자 

3. 가능한 각자의 module에서 노출시킬것.(하나의 모듈에서 just that one 하지 말아라)

4. 모듈간의 interactions를 정의할때, isolation testable 하게 만들어라

5. device가 offline mode일때도 usable하게 동작하기 위해서 fresh data as possible 을 유지해라

6. Repository는 one data source as the single source of truth 해야한다.


Addendum: exposing network status

- Resource class를 사용하여 network status를 노출시켜 data and status 둘다 encapsulate 하는 방법

//a generic class that describes a data with a status
public class Resource<T> {
   
@NonNull public final Status status;
   
@Nullable public final T data;
   
@Nullable public final String message;
   
private Resource(@NonNull Status status, @Nullable T data, @Nullable String message) {
       
this.status = status;
       
this.data = data;
       
this.message = message;
   
}

   
public static <T> Resource<T> success(@NonNull T data) {
       
return new Resource<>(SUCCESS, data, null);
   
}

   
public static <T> Resource<T> error(String msg, @Nullable T data) {
       
return new Resource<>(ERROR, data, msg);
   
}

   
public static <T> Resource<T> loading(@Nullable T data) {
       
return new Resource<>(LOADING, data, null);
   
}
}



// ResultType: Type for the Resource data
// RequestType: Type for the API response
public abstract class NetworkBoundResource<ResultType, RequestType> {
   
// Called to save the result of the API response into the database
   
@WorkerThread
   
protected abstract void saveCallResult(@NonNull RequestType item);

   
// Called with the data in the database to decide whether it should be
   
// fetched from the network.
   
@MainThread
   
protected abstract boolean shouldFetch(@Nullable ResultType data);

   
// Called to get the cached data from the database
   
@NonNull @MainThread
   
protected abstract LiveData<ResultType> loadFromDb();

   
// Called to create the API call.
   
@NonNull @MainThread
   
protected abstract LiveData<ApiResponse<RequestType>> createCall();

   
// Called when the fetch fails. The child class may want to reset components
   
// like rate limiter.
   
@MainThread
   
protected void onFetchFailed() {
   
}

   
// returns a LiveData that represents the resource, implemented
   
// in the base class.
   
public final LiveData<Resource<ResultType>> getAsLiveData();
}
public abstract class NetworkBoundResource<ResultType, RequestType> {
   
private final MediatorLiveData<Resource<ResultType>> result = new MediatorLiveData<>();

   
@MainThread
   
NetworkBoundResource() {
        result
.setValue(Resource.loading(null));
       
LiveData<ResultType> dbSource = loadFromDb();
        result
.addSource(dbSource, data -> {
            result
.removeSource(dbSource);
           
if (shouldFetch(data)) {
                fetchFromNetwork
(dbSource);
           
} else {
                result
.addSource(dbSource,
                        newData
-> result.setValue(Resource.success(newData)));
           
}
       
});
   
}

   
private void fetchFromNetwork(final LiveData<ResultType> dbSource) {
       
LiveData<ApiResponse<RequestType>> apiResponse = createCall();
       
// we re-attach dbSource as a new source,
       
// it will dispatch its latest value quickly
        result
.addSource(dbSource,
                newData
-> result.setValue(Resource.loading(newData)));
        result
.addSource(apiResponse, response -> {
            result
.removeSource(apiResponse);
            result
.removeSource(dbSource);
           
//noinspection ConstantConditions
           
if (response.isSuccessful()) {
                saveResultAndReInit
(response);
           
} else {
                onFetchFailed
();
                result
.addSource(dbSource,
                        newData
-> result.setValue(
                               
Resource.error(response.errorMessage, newData)));
           
}
       
});
   
}

   
@MainThread
   
private void saveResultAndReInit(ApiResponse<RequestType> response) {
       
new AsyncTask<Void, Void, Void>() {

           
@Override
           
protected Void doInBackground(Void... voids) {
                saveCallResult
(response.body);
               
return null;
           
}

           
@Override
           
protected void onPostExecute(Void aVoid) {
               
// we specially request a new live data,
               
// otherwise we will get immediately last cached value,
               
// which may not be updated with latest results received from network.
                result
.addSource(loadFromDb(),
                        newData
-> result.setValue(Resource.success(newData)));
           
}
       
}.execute();
   
}

   
public final LiveData<Resource<ResultType>> getAsLiveData() {
       
return result;
   
}
}
class UserRepository {
   
Webservice webservice;
   
UserDao userDao;

   
public LiveData<Resource<User>> loadUser(final String userId) {
       
return new NetworkBoundResource<User,User>() {
           
@Override
           
protected void saveCallResult(@NonNull User item) {
                userDao
.insert(item);
           
}

           
@Override
           
protected boolean shouldFetch(@Nullable User data) {
               
return rateLimiter.canFetch(userId) && (data == null || !isFresh(data));
           
}

           
@NonNull @Override
           
protected LiveData<User> loadFromDb() {
               
return userDao.load(userId);
           
}

           
@NonNull @Override
           
protected LiveData<ApiResponse<User>> createCall() {
               
return webservice.getUser(userId);
           
}
       
}.getAsLiveData();
   
}
}


공유

댓글