翻译官方文档Guide to App Architecture

官方文档链接:https://developer.android.google.cn/topic/libraries/architecture/guide.html

1.前言


由于用户在使用某一功能时会涉及不同的应用程序,需要不断地切换流程和任务。举个例子,在使用社交软件分享照片时,会需要打开摄像头,也会需要从图库中选择文件,而将数据返回社交软件的过程中,有可能会有电话打来。这一下子启动了许多应用程序,但是移动设备资源有限,操作系统随时会杀死一些应用程序来腾出空间给新的应用。所以应用程序组件的存在不由开发者控制,不应该存储数据或状态,更不应该彼此依赖。

之前通过生命周期进行相关操作,但这引入另一套逻辑,增加代码的复杂度。

2.常见架构原则


2.1.View层分离

使Activity和Fragment中的代码尽可能的少,只处理与界面或与操作系统的交互。因为这些类是操作系统和应用程序的中间件,不由开发者控制,所以应该最小化依赖。

2.2.Model层驱动

通过持久化模型驱动界面,具有两点好处:

  • 当操作系统释放组件资源时,用户数据也不会丢失;
  • 当网络连接状态不好时,应用程序也能正常工作。

持久化模型独立于组件,不受系统的控制。基于它的应用程序可以保证界面代码简单,同时业务逻辑具有可测试性。

3.推荐的应用框架


没有一种架构适用于所有场景。意味着,这是一个好的起点,但若已经有更适合的了,是不需要改变的。以网络获取并显示用户配置信息为例子,展示架构组件的使用方式。

3.1.建立用户界面

界面包含一个UserProfileFragment.java的组件类和user_profile_layout.xml的布局文件。为了驱动界面,基于ViewModel类创建UserProfileViewModel.java的数据模型来保存信息。数据元素主要包括:

  • The User ID:用户标识符。最好通过Fragment Arguments传入Fragment,那样操作系统销毁应用进程时,id将被保存以便重启时使用。
  • The User object:一个POJO类保存用户数据。

一个ViewModel给一个特定的界面组件(Activity或Fragment)提供数据,并处理业务逻辑中数据相关的操作,如调用其它组件来加载数据或转发用户的修改。ViewModel与View隔离,不受屏幕旋转导致重建等Configuration Changes的影响。而Activity或Fragment则是UI Controller,与用户行为和ViewModel进行交互。

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

目前使用LifecycleFragment代替Fragment,等到lifecycles版本稳定后,支持库中的Fragment将实现LifecycleOwner接口。

ViewModel与Presenter最大的不同是,Presenter内部封装着一系列的行为,而ViewModel持有的是数据(状态),那么数据如何传递呢?这里就得提LiveData了。

LiveData是一个可被观察的数据持有者。它让应用中的组件观察自己的变化,却不需要显式的和刚性的依赖。LiveData同时会监听应用组件(Activity,Fragment,Services)的生命周期状态,并且做正确的事情来防止对象的内存泄露。

如果已经使用RxJava或Agera库,你可以继续使用来替代LiveData。不过当你使用它们时,确保正确处理生命周期,例如:LifecycleOwner调用了onStop()方法,需暂停相关的数据流;LifecycleOwner调用了onDestory()方法,需销毁相关的数据流。你也可以添加android.arch.lifecycle:reactivestreams工件来让LiveData和其它的响应流库(RxJava2)一起使用。

现在,用LiveData<User>来替代User,这样当数据改变时,Fragment将会收到通知。更好的是,LiveData是支持生命周期的,当自己不再被使用时,自动清理引用。

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

与其它使用观察回调的库不同。当Fragment不处于活跃状态时,是不会回调的,所以不需要手动在Fragment的onStop()方法中停止观察数据;当Fragment销毁了,LiveData将会移除观察者,释放资源。

不需要做额外的事,ViewModel在配置改变时,将自动恢复相同的ViewModel实例及对当前数据的回调。

3.2.获取数据

将ViewModel和Fragment连接后,还需要ViewModel获取到用户数据。这里,假设使用Retrofit库来访问后端接口。

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中直接获取数据并复制给User对象,但随着项目的变大,将越来越难以维护,而且给ViewModel太多的职责,与单一职责的原则相违背。此外,ViewModel的活动范围与Activity或Fragment的生命周期相关,即生命周期结束后将丢失所有数据。为此,引入了Repository这个概念。

Repository是专门用来处理数据操作的。知道从什么地方获取数据和调用什么接口更新数据,是不同数据源之间的中转,例如持久化模型、Web服务、缓存等。

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不知道与什么数据源交互,便于替换。但是UserRepository需要Webservice实例,若通过构造方法提供,每个使用Webservice的类都得知道它的构造方法,使依赖变得复杂,同时都创建对象将占用大量资源。这里提供两种方法参考:

  • 依赖注入:允许类定义它们之间的依赖而不需要立马构建出来,等到运行时,由其它的类提供这些依赖。
  • 服务定位:提供一个仓库,类可以从中获取它们所需的依赖而不是创建它们。相较于依赖注入,它更容易实现。

这些模式可以方便地扩展代码,因为提供了清晰的方式管理依赖,而不需要增加其它代码。更重要的是它们都很容易测试

3.3.ViewModel和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;
    }
}
3.4.缓存数据

若Repository只实现一个数据源,则会显得不太实用。需增加数据的持有,当用户再次进入界面时不用重新加载,不然会浪费宝贵的网络带宽和迫使用户等待新的请求。为此,给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;
    }
}
3.5.持久化数据

内存缓存只能针对屏幕切换等当前应用进程存在的情况,但若进程被系统杀死,则仍需重新网络请求。为了不使用移动网络重新获取相同的数据,可以通过缓存Web请求来解决。可当场景是通过两个不同类型的请求来获取相同类型的数据时,会出现显示不一致的问题,导致需手动合并它们。正确的做法是数据持久化,在数据库中完成合并操作。

Room是一个对象映射库,通过最少的样板代码实现本地数据持久化。在编译时会验证每条查询的样式,将中断SQL查询的错误反映在编译期而不是运行时。Room封装了一些与原始SQL表和查询相关的底层实现细节,也允许观察数据库中数据(包括集合和连接查询)的变化,并通过LiveData对象来反映。此外,它还显式地定义了线程约束来规避常见问题,比如主线程上访问存储。

如果熟悉其它持久化解决方案像SQLite ORM或不同的数据库Realm,就不需要更换,除非Room的功能与用例很契合。

// 创建表相关的样式
@Entity
class User {
  @PrimaryKey
  private int id;
  private String name;
  private String lastName;
  // getters and setters for fields
}
// 创建数据访问对象
@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();
}

load方法能直接返回LiveData<User>,是很便利的。当Room发现数据库数据被改变了,且至少有一个活跃的相关的观察者,将自动通知它们更新相关操作。但是alpha1版本中,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,这有利于测试,例如,提供伪造的UserRepository来测试UserProfileViewModel。

有些情况下,例如下拉刷新,通过界面向用户展示当前进行中的网络操作是很重要的。好的做法是将界面操作与实际数据分离,因为数据容易被各种原因更新(例如,获取一个朋友列表,可能会再次获取到相同的user并触发LiveData更新)。从界面的角度来看,动态请求实际上只是另一个数据点,类似其它任何数据片段(如,User对象)。这里有两种常用解决方法:

  • getUser 方法返回的LiveData添加网络操作的状态。
  • 在Repository中提供另一个公开的方法以返回刷新的状态。尤其适合专门响应用户操作(下拉刷新),在界面中展示请求的网络状态。

需注意单一数据源原则。不同的后端接口返回相同的数据(粒度不同)是一种常见情况,但在请求的间隙,服务端的数据可能发生改变。若Repository直接返回网络请求,则导致界面显示冲突。这就是为什么在上面UserRepository实现时,网络请求的数据仅仅存到数据库中,触发LiveData的刷新。推荐,DataBase是应用唯一的数据源,而Repository是应用其它部分的唯一数据源。

3.6.测试

很容易将代码分成几个模块进行测试。

  • 用户界面和交互:这是唯一使用设备界面测试的地方。最好使用Espresso测试界面代码,因为Fragment只与ViewModel交互,提供一个mock的ViewModel足够完整地测试这个界面。
  • ViewModel:通过JUnit即可测试,仅mock所需的UserRepository。
  • UserRepository:也可以通过JUnit测试,仅mock所需的Webservice和UserDao。主要测试是否正常调用网络服务和存储结果到数据库,以及数据被缓存或更新后没有多余的请求。由于两者都是接口,除了mock它们,还能为更复杂的测试用例模拟实现它们。
  • UserDao:推荐仅使用设备测试。因为测试过程不涉及界面,仍能保持很快的运行速度。可以创建内存数据库,以确保测试没有任何副作用(如改变磁盘上的数据库文件)。同时,Room也允许指定数据库,通过提供SupportSQLiteOpenHelper的实现进行单元测试。但通常不推荐,因为手机和电脑上的SQLite版本不一样。
  • Webservice:测试应该不依赖于外部,所以测试Webservice时不能通过网络访问后台。有许多库可以做到这点,例如,MockWebServer库能为你的测试创建本地网络服务。
  • 测试工件:架构组件提供一个android.arch.core:core-testing工件来控制后台线程,包含两个JUint规则:
    • InstantTaskExecutorRule:此规则可强制架构组件在调用的线程上立即执行任何后台操作。
    • CountingTaskExecutorRule:此规则能被用于设备测试中等待架构组件的后台操作或者连接到Espresso作为闲置资源。
3.7.最终架构
AAC.png

4.指导原则


  • 在Manifest中定义的入口点,如:acitivy,fragment,broadcast receiver等,不是数据源。相反,它们应该只是协调与该入口点相关的数据子集。由于每个应用程序组件的存活时间很短,这取决于用户与其设备的交互以及运行时的总体状况,所以任何入口点都不应该成为数据源。
  • 严格的在应用程序的各个模块之间创建明确的责任界限。例如:不要让网络加载数据的代码被多个类或包使用。同样,不要将不相关的模块(如:数据缓存和数据绑定)放到同一个类中。
  • 每个模块尽可能少地暴露内部。不要尝试暴露模块内部哪怕一个地方的实现细节。你可能会在短期内节省一些时间,但是随着项目的发展,你将会花更多的时间来偿还。
  • 当定义模块间的交互时,考虑如何让每个模块能独立的测试。例如,拥有一个定义良好的、从网络获取数据的接口模块,将会使持久化数据到本地数据库的行为更易于测试。相反,如果将两个模块的逻辑放在一个地方,或者将网络相关的代码扩散到整个项目,测试将会变的非常困难(并非不可能)。
  • 应用程序的核心才是重点。不要花费时间重复造轮子或一次又一次的编写相同的样板代码。相反,将精力集中在应用程序的核心上,让AAC和其它优秀的库来处理重复的样板代码。
  • 持久化尽可能多的相关最新数据,以便应用程序在设备处于离线模式时还可以使用。即使你可以享用稳定高速的网络连接,但是你的用户可能无法享用。
  • Repository应该指定单一的数据源。每当应用程序需要访问数据时,数据应该始终来源于一个地方。

5.暴露网络状态


对于上面提到的网络状态问题,可以使用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类来复用。

Load.png

首先观察数据库的资源响应数据的更新。当从数据库中获取时,先判断得到的结果是否符合使用要求,不然就从网络获取。若想从网络更新时显示存储数据,它们可以同时进行。若网络加载成功,将结果存到数据库中,再次触发加载流程;若加载失败,直接调用失败操作。

新的数据存到磁盘上后,需从数据库重新取数据,若数据库可以发送改变消息,通常不用这么做。但是,依赖数据库发送改变消息,也会有些问题,因为数据更新后可能并没有变化,数据库将不会发送改变信息。同样,也不希望直接使用网络请求到的结果,因为这不符合单一数据源原则,哪怕也会触发数据库存储更新。最后,不希望没新数据更新的情况下发送SUCCESS标志,这会给客户端错误的信息。

// 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
    public final LiveData<Resource<ResultType>> getAsLiveData() {
        return result;
    }
}

上面的类定义两种类型参数(ResultType,RequestType),是考虑到网络获取的数据类型与本地使用的数据类型不匹配。而使用ApiResponse作为网络请求,是因为它对Retrofit2.Call进行简单的封装,将返回类型转成LiveData。

对于上面提出的要求,可以通过以下具体实现:

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

现在,用NetworkBoundResource替换之前UserRepository的获取数据的操作。由于Webservice和UserDao是由项目决定类型,无法提取到NetworkBoundResource中,所以相关操作放在UserRepository中实现。

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();
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343