android

应用程序架构指南

2018-08-19  本文已影响21人  鹿小纯0831

本指南适用于过去构建应用程序基础知识的开发人员,现在希望了解构建强大的,生产质量的应用程序的最佳实践和建议的体系结构。

本页假定您熟悉Android Framework。 如果您不熟悉应用程序开发,请查看入门培训系列,其中包含本指南的先决条件主题。

一、应用开发者面临的常见问题

与传统的桌面版本不同,在大多数情况下,它们只有一个来自启动器快捷方式的入口点并作为单个整体流程运行,Android应用程序的结构要复杂得多。典型的Android应用程序由多个应用程序组件构成,包括活动,片段,服务,内容提供程序和广播接收器。

大多数这些应用程序组件都在应用程序清单中声明,Android操作系统使用该清单来决定如何将您的应用程序集成到其设备的整体用户体验中。虽然如前所述,桌面应用程序传统上是一个单一的流程,但是正确编写的Android应用程序需要更加灵活,因为用户在设备上编写不同的应用程序,不断切换流程和任务。

例如,考虑当您在自己喜欢的社交网络应用中分享照片时会发生什么。该应用程序触发相机意图,Android操作系统启动相机应用程序来处理请求。此时,用户离开社交网络应用程序,但他们的体验是无缝的。反过来,相机应用程序可能会触发其他意图,例如启动文件选择器,这可能会启动另一个应用程序。最终用户回到社交网络应用程序并共享照片。此外,在此过程中的任何时刻,用户都可能被电话打断,并在完成电话呼叫后回来分享照片。

在Android中,这种跳频行为很常见,因此您的应用必须正确处理这些流量。请记住,移动设备受资源限制,因此在任何时候,操作系统都可能需要杀死某些应用程序以为新设备腾出空间。

所有这一切的重点在于,您的应用程序组件可以单独启动,也可以无序启动,并且可以由用户或系统随时销毁。由于应用程序组件是短暂的,并且它们的生命周期(当它们被创建和销毁时)不在您的控制之下,因此您不应在应用程序组件中存储任何应用程序数据或状态,并且您的应用程序组件不应相互依赖。

二、共同的构建原则

如果您无法使用应用程序组件来存储应用程序数据和状态,那么应该如何构建应用程序?

您应该关注的最重要的事情是在您的应用中分离关注点。在Activity或Fragment中编写所有代码是一个常见的错误。任何不处理UI或操作系统交互的代码都不应该在这些类中。尽可能保持精简可以避免许多与生命周期相关的问题。不要忘记你没有这些类,它们只是胶水类,体现了操作系统和你的应用程序之间的契约。 Android操作系统可能会根据用户交互或其他因素(如内存不足)随时销毁它们。最好尽量减少对它们的依赖,以提供可靠的用户体验。

第二个重要原则是您应该从模型中驱动UI,最好是持久模型。持久性是理想的两个原因:如果操作系统破坏您的应用程序以释放资源,您的用户将不会丢失数据,即使网络连接不稳定或未连接,您的应用程序也将继续工作。模型是负责处理应用程序数据的组件。它们独立于应用程序中的视图和应用程序组件,因此它们与这些组件的生命周期问题隔离开来。保持UI代码简单且没有应用程序逻辑,使其更易于管理。将您的应用程序基于具有明确定义的数据管理职责的模型类,将使其可测试且您的应用程序保持一致。
三、推荐的应用架构
在本节中,我们将演示如何使用Architecture Components通过用例来构建应用程序。

注意:没有一种方法可以编写最适合每种情况的应用程序。 话虽这么说,这个推荐的架构应该是大多数用例的一个很好的起点。 如果您已经有了编写Android应用程序的好方法,则无需更改。

想象一下,我们正在构建一个显示用户配置文件的UI。 此用户配置文件将使用REST API从我们自己的私有后端获取。

构建用户界面

UI将包含一个片段UserProfileFragment.java及其对应的布局文件user_profile_layout.xml

为了驱动UI,我们的数据模型需要包含两个数据元素。

ViewModel为特定UI组件(例如片段或活动)提供数据,并处理与数据处理的业务部分的通信,例如调用其他组件来加载数据或转发用户修改。ViewModel不了解View,也不受配置更改的影响,例如由于轮换而重新创建活动。

现在我们有3个文件。

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);
    }
}

现在,我们有这三个代码模块,我们如何连接它们? 毕竟,当设置ViewModel的用户字段时,我们需要一种方法来通知UI。 这是LiveData类的用武之地。
LiveData是一个可观察的数据持有者。 它允许应用程序中的组件观察LiveData对象的更改,而无需在它们之间创建明确且严格的依赖关系路径。 LiveData还尊重应用程序组件(活动,片段,服务)的生命周期状态,并做正确的事情来防止对象泄漏,以便您的应用程序不会消耗更多内存。

注意:如果您已经在使用像RxJavaAgera这样的库,则可以继续使用它们而不是LiveData。 但是当您使用它们或其他方法时,请确保正确处理生命周期,以便在相关LifecycleOwner停止时数据流暂停,并在销毁LifecycleOwner时销毁流。 您还可以添加android.arch.lifecycle:reactivestreams工件以将LiveData与另一个反应流库(例如,RxJava2)一起使用。

现在,我们使用LiveData <User>替换UserProfileViewModel中的User字段,以便在更新数据时通知片段。 LiveData的优点在于它具有生命周期意识,并且在不再需要时会自动清理引用。

public class UserProfileViewModel extends ViewModel {
    ...
    private User user;
    private LiveData<User> user;
    public LiveData<User> getUser() {
        return user;
    }
}

现在我们修改UserProfileFragment以观察数据并更新UI

@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
    super.onActivityCreated(savedInstanceState);
    viewModel.getUser().observe(this, user -> {
      // update UI
    });
}

每次更新用户数据时,都会调用onChanged回调并刷新UI

如果您熟悉使用可观察回调的其他库,您可能已经意识到我们不必覆盖片段的onStop()方法来停止观察数据。 LiveData不需要这样做,因为它可以识别生命周期,这意味着除非片段处于活动状态(收到onStart()但没有收到onStop()),否则它不会调用回调。当片段收到onDestroy()时,LiveData也会自动删除观察者。

我们也没有做任何特殊处理配置更改(例如,用户旋转屏幕)。 ViewModel会在配置更改时自动恢复,因此只要新片段生效,它就会收到相同的ViewModel实例,并且会立即使用当前数据调用回调。这就是ViewModels不应该直接引用Views的原因;它们可以比View的生命周期更长久。请参阅ViewModel的生命周期。

获取数据

现在我们将ViewModel连接到片段,但ViewModel如何获取用户数据? 在此示例中,我们假设我们的后端提供REST API。 我们将使用Retrofit库来访问我们的后端,尽管您可以自由地使用不同的库来实现相同的目的。

这是我们的改造Web服务,它与我们的后端通信:

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类负有太多责任,这违背了我们之前提到的关注点分离原则。 此外,ViewModel的范围与活动或片段生命周期相关联,因此在其生命周期结束时丢失所有数据是一种糟糕的用户体验。 相反,我们的ViewModel会将此工作委托给新的Repository模块。

存储库模块负责处理数据操作。 它们为应用程序的其余部分提供了一个干净的API。 他们知道从何处获取数据以及在更新数据时要进行的API调用。 您可以将它们视为不同数据源(持久模型,Web服务,缓存等)之间的中介。

下面的UserRepository类使用WebService来获取用户数据项。

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;
    }
}

即使存储库模块看起来不必要,它也是一个重要的目的; 它从应用程序的其余部分抽象出数据源。 现在我们的ViewModel不知道Webservice获取数据,这意味着我们可以根据需要将其交换为其他实现。

注意:为简单起见,我们省略了网络错误情况。 有关公开错误和加载状态的替代实现,请参阅附录:公开网络状态。

管理组件之间的依赖关系:
上面的UserRepository类需要一个Webservice实例来完成它的工作。 它可以简单地创建它,但要做到这一点,它还需要知道Webservice类的依赖关系来构建它。 这将使代码显着复杂化和复制(例如,需要Webservice实例的每个类都需要知道如何使用其依赖关系来构造它)。 此外,UserRepository可能不是唯一需要Web服务的类。 如果每个类都创建一个新的WebService,那么它将非常耗费资源。
您可以使用两种模式来解决此问题:

连接ViewModel和存储库

现在我们修改UserProfileViewModel以使用存储库。

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;
    }
}

缓存数据

上面的存储库实现适用于抽象Web服务的调用,但因为它只依赖于一个数据源,所以它不是很有用。

上面的UserRepository实现的问题是,在获取数据之后,它不会将其保留在任何位置。 如果用户离开UserProfileFragment并返回到它,则应用程序将重新获取数据。 这有两个原因:它浪费了宝贵的网络带宽并迫使用户等待新查询完成。 为了解决这个问题,我们将向UserRepository添加一个新的数据源,用于将User对象缓存在内存中。

@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;
    }
}
持久数据

在我们当前的实现中,如果用户旋转屏幕或离开并返回到应用程序,则现有UI将立即可见,因为存储库从内存缓存中检索数据。但是,如果用户离开应用程序并在Android操作系统杀死进程后数小时后回来会发生什么?

通过当前的实现,我们需要从网络中再次获取数据。这不仅是一种糟糕的用户体验,而且还浪费,因为它将使用移动数据重新获取相同的数据。您可以通过缓存Web请求来解决此问题,但这会产生新问题。如果相同的用户数据显示来自另一种类型的请求(例如,获取朋友列表)会发生什么?然后,您的应用可能会显示不一致的数据,这充其量只是令人困惑的用户体验。例如,相同用户的数据可能不同地显示,因为可以在不同时间执行朋友列表请求和用户请求。您的应用需要合并它们以避免显示不一致的数据。

处理此问题的正确方法是使用持久模型。这是Room persistence library来救援的地方。

Room是一个对象映射库,它提供本地数据持久性和最少的样板代码。 在编译时,它根据模式验证每个查询,以便损坏的SQL查询导致编译时错误而不是运行时失败。Room抽象了使用原始SQL表和查询的一些底层实现细节。 它还允许观察对数据库数据(包括集合和连接查询)的更改,通过LiveData对象公开此类更改。 此外,它还明确定义了解决常见问题的线程约束,例如访问主线程上的存储。

注意:如果您的应用已使用其他持久性解决方案(如SQLite对象关系映射(ORM)),则无需使用Room替换现有解决方案。 但是,如果您正在编写新应用或重构现有应用,我们建议您使用Room来保留应用的数据。 这样,您就可以利用库的抽象和查询验证功能。

要使用Room,我们需要定义本地模式。 首先,使用@Entity注释User类,将其标记为数据库中的表。

@Entity
class User {
  @PrimaryKey
  private int id;
  private String name;
  private String lastName;
  // getters and setters for fields
}

然后,通过为您的应用扩展RoomDatabase来创建数据库类:

@Database(entities = {User.class}, version = 1)
public abstract class MyDatabase extends RoomDatabase {
}

请注意,MyDatabase是抽象的。 Room自动提供它的实现。 有关详细信息,请参阅房间文档

现在我们需要一种将用户数据插入数据库的方法。 为此,我们将创建一个数据访问对象(DAO)。

@Dao
public interface UserDao {
    @Insert(onConflict = REPLACE)
    void save(User user);
    @Query("SELECT * FROM user WHERE id = :userId")
    LiveData<User> load(String userId);
}

然后,从我们的数据库类中引用DAO。

@Database(entities = {User.class}, version = 1)
public abstract class MyDatabase extends RoomDatabase {
    public abstract UserDao userDao();
}

请注意,load方法返回LiveData <User>。 会议室知道数据库何时被修改,并且在数据发生变化时会自动通知所有活动的观察者。 因为它使用LiveData,所以这将是有效的,因为只有在至少有一个活动观察者的情况下它才会更新数据。

注意:Room根据表格修改检查失效,这意味着它可能会发送误报通知。

现在我们可以修改UserRepository以合并Room数据源。

@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中数据的来源,我们也不需要更改UserProfileViewModel或UserProfileFragment。这是抽象提供的灵活性。这对于测试也很有用,因为您可以在测试UserProfileViewModel时提供虚假的UserRepository。

现在我们的代码已经完成。如果用户几天后回到相同的用户界面,他们会立即看到用户信息,因为我们已经保留了用户信息。同时,如果数据陈旧,我们的存储库将在后台更新数据。当然,根据您的使用情况,如果持久数据太旧,您可能不希望显示这些数据。

在某些用例中,例如pull-to-refresh,如果当前正在进行网络操作,则UI必须向用户显示。将UI操作与实际数据分开是一种很好的做法,因为它可能因各种原因而更新(例如,如果我们获取朋友列表,则可能会再次获取相同的用户,从而触发LiveData <User>更新)。从UI的角度来看,飞行中有请求的事实只是另一个数据点,类似于任何其他数据点(如User对象)。
这个用例有两种常见的解决方案:

单一的事实来源

不同的REST API端点通常会返回相同的数据。 例如,如果我们的后端有另一个返回朋友列表的端点,则同一个用户对象可能来自两个不同的API端点,可能是不同的粒度。 如果UserRepository按原样从Webservice请求返回响应,则我们的UI可能会显示不一致的数据,因为这些请求之间的数据可能会在服务器端发生更改。 这就是为什么在UserRepository实现中,Web服务回调只是将数据保存到数据库中。 然后,对数据库的更改将触发活动LiveData对象的回调。

在此模型中,数据库充当单一事实来源,应用程序的其他部分通过存储库访问它。 无论您是否使用磁盘缓存,我们都建议您的存储库将数据源指定为应用程序其余部分的唯一真实来源。

测试

我们已经提到分离的一个好处是可测试性。让我们看看我们如何测试每个代码模块。

最终的架构

下图显示了我们推荐的体系结构中的所有模块以及它们如何相互交互:


image

四、指导原则

编程是一个创造性的领域,构建Android应用程序也不例外。有许多方法可以解决问题,无论是在多个活动或片段之间传递数据,检索远程数据并在本地持久保存以用于脱机模式,还是任何其他非常重要的应用程序遇到的常见场景。

虽然以下建议不是强制性的,但我们的经验是,遵循它们将使您的代码库从长远来看更加强大,可测试和可维护。

五、附录:暴露网络状态

在上面推荐的应用程序架构部分中,我们故意省略网络错误和加载状态以保持样本简单。 在本节中,我们演示了一种使用Resource类公开网络状态以封装数据及其状态的方法。

以下是一个示例实现:

//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);
    }
}

因为在从磁盘显示数据时从网络加载数据是一个常见的用例,我们将创建一个可以在多个地方重用的帮助程序类NetworkBoundResource。 以下是NetworkBoundResource的决策树:

image
它首先观察资源的数据库。 当第一次从数据库加载条目时,NetworkBoundResource会检查结果是否足以分派和/或是否应从网络中获取。 请注意,这两个都可以同时发生,因为您可能希望在从网络更新缓存数据时显示缓存数据。

如果网络调用成功完成,它会将响应保存到数据库中并重新初始化流。 如果网络请求失败,我们会直接发送故障。

注意:将新数据保存到磁盘后,我们会重新初始化数据库中的流,但通常我们不需要这样做,因为数据库将调度更改。 另一方面,依靠数据库来调度更改将依赖于副作用,这是不好的,因为如果数据没有改变,数据库可以避免调度更改,它可能会中断。 我们也不希望调度从网络到达的结果,因为这将违背单一事实来源(可能在数据库中存在将更改保存值的触发器)。 我们也不想在没有新数据的情况下发送SUCCESS,因为它会向客户端发送错误的信息。

以下是NetworkBoundResource类为其子级提供的公共API

// 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();
}

请注意,上面的类定义了两个类型参数(ResultTypeRequestType),因为从API返回的数据类型可能与本地使用的数据类型不匹配。

另请注意,上面的代码使用ApiResponse进行网络请求。 ApiResponse是一个简单的Retrofit2.Call类包装器,用于将其响应转换为LiveData

下面是NetworkBoundResource类的其余实现:

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;
    }
}

现在,我们可以使用NetworkBoundResource在存储库中编写磁盘和网络绑定的User实现。

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();
    }
}
上一篇 下一篇

猜你喜欢

热点阅读