泰然城AndroidAppRn集成之路

泰然城www.trc.com)是泰然旗下定位于中国新兴中产阶级的品质生活综合服务平台,致力于为用户打造一个网络的品质生活城市。   在泰然城,用户可以享受更符合自己气质与精神共鸣的产品和服务。目前布局于泰然易购、优驷卡、泰然金融和小泰公益四个业务板块。其中,泰然易购和优驷卡为消费者提供精选的、有品质的购物、购车服务;泰然金融为消费者提供服务于以上生活场景的金融服务;小泰公益是面向公众开放的专业、透明、有责任感的公益服务平台。   未来,围绕用户需求的升级,泰然城将从健康、旅游、娱乐、文化等维度,通过新颖的交互体验和服务,赋予用户一个崭新的互联网城市市民属性,让市民遨游网络畅享泰然生活。

各位都知道,APP的版本迭代太快,需要频繁升级,用户体验不够好。尤其是强制更新,很容易造成用户流失。APP迭代太慢,又会耽误各业务线的功能上线。泰然城APP随着业务的增多,每次发版都需要协调金融、电商、收银台、账户中心等各业务模块,还经常因为部分业务等强制更新需求而强制更新整个APP。另外APP的发版像排好了时间的列车,每月两次,后端业务的上线需要配合APP的上线节奏,因而频频受制约于固定的发版节奏。解决发版难造成的后台业务上线不够灵活、独立,同时线上BUG修复需要发新版本解决,影响用户体验,为此泰然城的移动技术团队开始寻求解决方案。一开始我们把目光放在了Android的热更新方案,但AppStore出台了禁止iOS的一些热更新技术方案后,我们把目光投向了ReactNative方案,毕竟只有AndroidApp支持动态更新还是解决不了发版排期引起的问题。

为什么是RN?因为RN在大大减少新版发布的前提下,可以支持业务快速上线。同时,与H5页面相比,RN有着近乎原生的体验。经过和CTO及各业务线上的技术负责人沟通,我们移动团队开始了我们的RN集成之路。

在RN集成之前,我们的APP是典型的Hybrid框架,也就是原生和H5混合开发,同时Android项目推行了模块化开发。我们通过定义各种Uri来支持H5向原生页面的跳转。因此,在集成RN功能,我们水到渠成的想到了通过Uri的方式来跳转到RN页面。就这样我们APP实现了Native、H5、RN页面的统一路由。梦想是丰满的,显示是骨感的。接下来,我们选几个主要的部分去讲解我们的设计方案。

RnHostActivity负责加载RN页面的Activity:我们简单的用Fragment的设计来类比一下RN的设计。Fragment其实就是对View的一个包装,主要加入了Activity的生命周期函数的回调。与之相对的RN最终也是通过对JS的解释创建了一个View(及子ViewTree),同时把生命周期函数传递到RN内部。其中ReactInstanceManager对应我们Android开发中的FragmentManager,不同的是写Fragment的工作是放在了RN工程里。对于RnHostActivity要处理的事情就是根据传递过来的模块名找到对应RN包信息。通过RnBundleManager下载、解压、缓存好RN的Bundle包,然后去加载初始化ReactInstanceManager获取一个View并添加进Activity里,同时维护好ReactInstanceManager的生命周期、返回键等,对于我们好奇的触屏事件则是直接通过View对触屏事件的处理的地方进行Hook。

public class RnHostActivity extends BaseActivity implements DefaultHardwareBackBtnHandler {

    private static final int OVERLAY_PERMISSION_REQ_CODE = 1;

    private ReactInstanceManager mReactInstanceManager;

    public static final String INTENT_KEY_BUNDLE_NAME = "INTENT_KEY_BUNDLE_NAME";

    public static final String INTENT_KEY_LAUNCH_OPTIONS = "INTENT_KEY_LAUNCH_OPTIONS";

    private RnBundle rnBundle;

    private String bundleName;

    private static HashMap managerHashMap = new HashMap<>();


    @Override

    protected void onCreate(Bundle savedInstanceState) {

        RnBundleManager.init(getApplication());

        super.onCreate(savedInstanceState);

        bundleName = getIntent().getStringExtra(INTENT_KEY_BUNDLE_NAME);

        boolean permissionAllowed = checkPermission();

        if (permissionAllowed) {

            load();

        }

    }


    private boolean checkPermission() {

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(this)) {

            Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,

                    Uri.parse("package:" + getPackageName()));

            if (null != intent.resolveActivity(getPackageManager()))

                startActivityForResult(intent, OVERLAY_PERMISSION_REQ_CODE);

            ToastUtil.showNormalToast("请打开泰然城悬浮窗权限,否则无法加载界面");

            return false;

        } else {

            return true;

        }

    }


    private void load() {

        if (ConfigHelper.getBoolean(PropertyKeys.RN_DEBUG_MODE, false)) {

            loadRnView(true);

        } else {

            //RnBundle包已下载则直接加载,未下载则弹出下载进度框,下载完成后再加载

            RnBundleManager.loadBundle(this, bundleName, new Callback<RnBundle>() {

                @Override

                public void onSuccess(RnBundle model) {

                    rnBundle = model;

                    if (rnBundle.enable) {

                        loadRnView(false);

                    } else {

                        ToastUtil.showNormalToast("该模块维护中,请稍后再试");

                        finish();

                    }

                }

                @Override

                public void onFail(ServerResultCode serverResultCode, String errorMessage) {

                    ToastUtil.showNormalToast(errorMessage);

                    finish();

                }

            });

        }

    }


    private void loadRnView(boolean debug) {

        ReactRootView reactRootView = new ReactRootView(this);

        String key = debug ? "debugKey" : rnBundle.component + rnBundle.version;

        mReactInstanceManager = managerHashMap.get(key);

        if (null == mReactInstanceManager) {

            ReactInstanceManagerBuilder builder = ReactInstanceManager.builder();

            if (debug) {

                builder.setJSMainModuleName("index.android");

            } else {

                builder.setJSBundleFile(RnBundleManager.getBundleJsFile(rnBundle).getPath());

            }

            builder.setApplication(getApplication())

                    .setCurrentActivity(this)

                    .addPackage(new MainReactPackage())

                    .addPackage(new RnUriPackage())

                    .setUseDeveloperSupport(debug)

                    .setInitialLifecycleState(LifecycleState.RESUMED);


            mReactInstanceManager = builder.build();

            ReactInstanceManagers.regist(mReactInstanceManager);

            managerHashMap.put(key, mReactInstanceManager);

        }

        reactRootView.startReactApplication(mReactInstanceManager, bundleName, getIntent().getBundleExtra(INTENT_KEY_LAUNCH_OPTIONS));

        setContentView(reactRootView);

        if (isAfterResumed()) {

            mReactInstanceManager.onHostResume(this, this);

        }

        if (isAfterPaused()) {

            mReactInstanceManager.onHostPause(this);

        }

    }


    @Override

    protected void onNewIntent(Intent intent) {

        super.onNewIntent(intent);

        if (null != mReactInstanceManager) {

            mReactInstanceManager.onNewIntent(intent);

        }

    }


    @Override

    public void invokeDefaultOnBackPressed() {

        super.onBackPressed();

    }


    @Override

    public void onBackPressed() {

        if (null != mReactInstanceManager) {

            mReactInstanceManager.onBackPressed();

        } else {

            super.onBackPressed();

        }

    }


    @Override

    protected void onPause() {

        super.onPause();

        if (mReactInstanceManager != null) {

            mReactInstanceManager.onHostPause(this);

        }

    }


    @Override

    protected void onResume() {

        super.onResume();

        if (mReactInstanceManager != null) {

            mReactInstanceManager.onHostResume(this, this);

        }

    }


    @Override

    protected void onDestroy() {

        super.onDestroy();

        ReactInstanceManagers.unregist(mReactInstanceManager);

        if (mReactInstanceManager != null) {

            mReactInstanceManager.onHostDestroy(this);

        }

    }


    @Override

    public boolean onKeyUp(int keyCode, KeyEvent event) {

        if (keyCode == KeyEvent.KEYCODE_MENU && mReactInstanceManager != null) {

            mReactInstanceManager.showDevOptionsDialog();

            return true;

        }

        return super.onKeyUp(keyCode, event);

    }


    @Override

    protected void onActivityResult(int requestCode, int resultCode, Intent data) {

        super.onActivityResult(requestCode, resultCode, data);

        if (requestCode == OVERLAY_PERMISSION_REQ_CODE) {

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && Settings.canDrawOverlays(this)) {

                if (Settings.canDrawOverlays(this)) {

                    load();

                } else {

                    finish();

                }

            }

        }

    }


    public static Intent newIntent(Context context, String bundleName, @Nullable Bundle launchOptions) {

        Intent intent = new Intent(context, RnHostActivity.class);

        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);

        intent.putExtra(INTENT_KEY_BUNDLE_NAME, bundleName);

        intent.putExtra(INTENT_KEY_LAUNCH_OPTIONS, launchOptions);

        return intent;

    }

}

路由

我们要设计一套页面路由方案(页面跳转方案),能够通过服务后台配置实现页面跳转的控制。在我们完成RN基础能力支持之后,我们会面临把原来的H5页面以及原生页面替换成RN实现的需求。比如商品详情的H5页面使用频率很高,交互要求也多,页面变化也比较频繁,为了提升用户体验,我们决定用RN的方式实现。等我们用RN开发完成之后,我们配置了一条路由规则,把原来跳转到商品详情页的https://www.trc.com/goods/detail/KSI1js73h1链接转换成trcrn://mall&page=detail&originUrl=XXXX,即可实现线上页面的热替换。

由于历史原因,APP内原生页面跳转都是通过Intent设定具体的Activity或加载具体的Fragment,这样以来我么的页面跳转就相当于是硬编码在APP里面。为此我们需要把所有的页面跳转替换成URI的方式跳转,这样才能和H5、Native的URI进行统一的处理。


关键的类UriDispatcher,对应上图中的Router,页面跳转事先拼好URL,然后使用UriDispatcher进行分发。在UriDispatcher内部根据路由规则进行路由转换,然后跳转到相应的页面。


public class UriDispatcher {

    private static Pattern pattern = Pattern.compile("^[a-z|A-Z]{1,}://");

    private static HashMap<String, String> routerMap;

    public static void setRouterConfig(HashMap<String, String> configMap) {

        routerMap = configMap;

    }


    public static void dispatchUri(Context context, @Nullable String url) {

        try {

            if (null == url) {

                return;

            }

            if (pattern.matcher(url).find()) {

                url = url.trim();

                url = transformUrl(url);

                Uri uri = Uri.parse(url);

                boolean handled = UriManager.handleUri(uri, context);

                if (!handled) {

                    switch (uri.getScheme().toLowerCase()) {

                        //SchemeManager处理不了的,没有预定义,一般是App版本低,新定义的Uri处理不了

                        case "trc":

                        case "taihe":

                        case "trcrn":

                            ToastUtil.showNormalToast("App版本太低,无法处理Url:" + url);

                            break;

                        default:

                            openLinkByThirdPartyApp(context, uri);

                    }

                }

            }

        } catch (Exception e) {

            ThrowableHunter.hunt(e);

        }

    }



    //根据路由规则进行URL转换

    public static String transformUrl(String url) {

        if (null != routerMap) {

            for (Map.Entry<String, String> entry : routerMap.entrySet()) {

                String key = entry.getKey();

                if (url.startsWith(key)) {

                    Uri mathcUri = Uri.parse(key);

                    Uri originUri = Uri.parse(url);

                    if (originUri.getHost().equals(mathcUri.getHost())) {

                        //Scheme Host 均匹配,认为匹配

                        return entry.getValue() + "&originUrl=" + SafeBase64.encodeString(url);

                    }

                }

            }

        }

        return url;

    }


    private static void openLinkByThirdPartyApp(final Context context, final Uri uri) {

        ...

    }


}

UriDispatcher分发示例代码片段,其中URL可以是http链接、也可以是自定义协议的链接

...

//跳转到应用首页

UriDispatcher.dispatchUri(getActivity(), "trc://main?page=home");

...

//打开H5页面,如果有路由配置,也可能被重定向到原生的页面

UriDispatcher.dispatchUri(getActivity(), "https://passport.trc.com/appprotocol/taihe_service.html");

...



关键类UriHandler、UriManager,UriManager负责找到URL对应的UriHandler实现类,根据UriHandler实现类的UriScheme和UriHost等注解进行匹配,如果匹配成功,创建该UriHandler实现类的一个实例去处理URL。如果该实例能够处理URL则返回true,UriManager则会停止遍历,并返回true,否则继续遍历寻找,如果没有找到任何匹配的UriHandler实现类,或找到了但该实现类(参数错误、缺失等原因)无法正确处理时,则返回false。UriManager最终返回true&false,方便调用方决定是否需要继续处理URL。

public interface UriHandler {

    boolean handle(@NonNull Uri uri, @Nullable Context context);

}

public class UriManager {


    private static final ArrayList> handlers = new ArrayList();

    private static final HashMap> cacheMap = new HashMap<>();


    public static boolean handleUri(Uri uri, @Nullable Context context) {

        try {

            String scheme = uri.getScheme();

            String host = uri.getHost();

            context = context != null ? context : Contexts.getInstance();

            String url = uri.toString();

            Class

            if (null == c) {

                for (Class

                    if (matchUri(scheme, host, clazz)) {

                        if (clazz.newInstance().handle(uri, context)) {

                            cacheMap.put(url, clazz);

                            return true;

                        }

                    }

                }

            } else {

                return c.newInstance().handle(uri, context);

            }

            return false;

        } catch (Exception e) {

            return false;

        }


    }



    private static boolean matchUri(String scheme, String host, Class<?> clazz) {

        //没有UriHost注解时候只匹配UriScheme,否则都要匹配

        if (clazz.isAnnotationPresent(UriScheme.class)) {

            if (scheme.equals(clazz.getAnnotation(UriScheme.class).value())) {

                if (!clazz.isAnnotationPresent(UriHost.class)) {

                    return true;

                } else if (host.equals(clazz.getAnnotation(UriHost.class).value())) {

                    return true;

                }

            }

        }

        return false;

    }


    public static final void registUriHandler(Class

        if (null != clazzes) return;

        for (Class

            handlers.add(c);

        }

    }


}

示例UriHandelr。UriHandler接口只有一个方法public boolean handle(@NonNull Uri uri, @Nullable Context context) ;返回true表示可以处理次URL,否则表示不能处理。UriManager会遍历所有的UriHandler实现类,根据其注解的UriScheme和UriHost等信息进行匹配,

/**

 * Created by SuperMan on 2017/5/26.

 * 根据ID去往相应的分类二级页面 trc://category?catId=XXXX&subId=YYYY

 */

@UriHost(“category")

@UriScheme("trc")

public class TrcPageCategoryUriHanlder implements UriHandler {

    @Override

    public boolean handle(@NonNull Uri uri, @Nullable Context context) {

        String catId = uri.getQueryParameter("catId");

        String subId = uri.getQueryParameter("subId");

        if (NullUtil.notEmpty(catId) && NullUtil.noEmpty(subId)) {

            FragmentHostActivity.openFragment(context, TrcCategoryLv2Fragment.newInstance(catId, subId));

        } else if (NullUtil.notEmpty(catId) && !NullUtil.noEmpty(subId)) {

            FragmentHostActivity.openFragment(context, TrcCategoryLv2Fragment.newInstance(catId));

        } else if (!NullUtil.notEmpty(catId) && NullUtil.noEmpty(subId)) {

            FragmentHostActivity.openFragment(context, TrcCategoryLv1Fragment.newInstance());

        } else {

            FragmentHostActivity.openFragment(context, TrcCategoryLv1Fragment.newInstance());

        }

        return true;

    }


}

对于跳转RN页面的UriHandler,在UriDispatcher的transformUrl(String url)方法里面,我们把originUrl作为参数传到RN页面,参数解析由RN负责。同时,我们在跳转RN页面时候,会带上一些基本的参数,例如RN的模块名称、目标页面名称等参数。在跳转到RnHostActivity后,我们根据模块名称去找到对应的Version、DownloadUrl等,然后加载RN的Bundle。

/**

 * Created by SuperMan on 2017/5/26.

 * 跳转到RN页面 trcrn://host?module=XXXX&page=YYYY&params=BASE64_ENCODED_JSON&originUrl=ZZZZ

 */

@UriHost("host")

@UriScheme("trcrn")

public class TrcRnUriHandler implements UriHandler {

    @Override

    public boolean handle(@NonNull Uri uri, @Nullable Context context) {

        String bundleName = uri.getQueryParameter("module");

        String page = uri.getQueryParameter("page");

        Bundle bundle = new Bundle();

        bundle.putString("page", page);

        if (UserConfig.isLogined()) {

            bundle.putString("phone", UserConfig.getUserAcctNo());

        }

        bundle.putString("channel", ChannelUtil.getChannel());

        bundle.putString("appVersion", AppConfig.getVersion());

        if (!TextUtils.isEmpty(AppConfig.getPushId())) {

            bundle.putString("pushid", AppConfig.getPushId());

        }

        bundle.putString("deviceId", AppConfig.getUuid());

        if (!TextUtils.isEmpty(uri.getQueryParameter("params"))) {

            String params = ParamsUtil.getBase64EncodedParameter(uri, "params");

            Map map = new Gson().fromJson(params, HashMap.class);

            for (Map.Entry entry : map.entrySet()) {

                bundle.putString(String.valueOf(entry.getKey()), String.valueOf(entry.getValue()));

            }

        }

        if (!TextUtils.isEmpty(uri.getQueryParameter("originUrl"))) {

            bundle.putString("originUrl", uri.getQueryParameter("originUrl"));

        }

        context.startActivity(RnHostActivity.newIntent(context, bundleName, bundle));

        return true;

    }

}


通信

Native跳转RN页面时传参:

发送广播EventName:"user_login", Params:token=XXXX

发送广播EventName:"user_logout", Params:无

启动RN时传参

page:进入RN模块后要跳转的页面 (如果不穿,则跳转到模块默认页面)

token:如果已登录状态,则进入RN模块会带上token

channel:APP的分发渠道,iOS统一“appstore",此参数必传

pushid:友盟推送的ID,此参数非必传,某些情况下pushid不能第一时间获取到

deviceId:唯一设备号,此参数必传

phone: 用户手机号,如果有的话会带上此参数(一般登录了就有此账户)

其他:osversion\mobilemod\provider

Native向RN广播公共事件:比如账号登陆、登出等

ReactInstanceManagers:记录所有使用中的ReactInstanceManager,需要的时候向RN发送广播事件

public class ReactInstanceManagers {

    private static List list = new LinkedList<>();


    public static void regist(ReactInstanceManager reactInstanceManager) {

        if (!list.contains(reactInstanceManager)) list.add(reactInstanceManager);

    }


    public static void unregist(ReactInstanceManager reactInstanceManager) {

        list.remove(reactInstanceManager);

    }


    public static List<ReactInstanceManager> getRegistManagers() {

        ArrayList reactInstanceManagers = new ArrayList<>();

        reactInstanceManagers.addAll(list);

        return reactInstanceManagers;

    }


    public static void sendEvent(String eventName, @Nullable Object data) {

        for (ReactInstanceManager manager : list) {

            ReactContext currentReactContext = manager.getCurrentReactContext();

            if (null != currentReactContext) {

                DeviceEventManagerModule.RCTDeviceEventEmitter jsModule = currentReactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class);

                if (null != jsModule) jsModule.emit(eventName, data);

            }

        }

    }


}

RN调Native方法:我们提供了一些基础的方法

dispatchUri(String uri),通过此方法RN相当于调用了原生的UriDispatcher.dispatchUri()方法,这也意味着我们实现了RN通过UriDispatcher跳转到原生、Native及其他RN模块页面的功能。

选择联系人工功能

选择照片功能

指纹识别功能

示例代码

public class Rn2NativeScanHandler extends ReactContextBaseJavaModule {



    @Override

    public String getName() {

        return "TRCEcardScanner";

    }


    public Rn2NativeScanHandler(ReactApplicationContext reactContext) {

        super(reactContext);


    }


    @ReactMethod

    public void scanEcard(final Callback success) {

        Context context = getCurrentActivity();

        Intent intent = QRCodeScanActivity.newIntent(context);

        BridgeActivity.startActivity(intent, new ActivityLifeCallback() {

            @Override

            public void onActivityResult(BridgeActivity activity, int resultCode, Intent data) {

                try {

                    activity.finish();

                    if (Activity.RESULT_OK == resultCode) {

                        final String ecardNo = data.getStringExtra(QRCodeScanActivity.RESULT_KEY);

                        RnPostHandler.postDelayed(new Runnable() {

                            @Override

                            public void run() {

                                try {

                                    success.invoke(ecardNo);

                                } catch (Exception e) {

                                    e.printStackTrace();

                                }

                            }

                        }, 250);

                    }

                } catch (Exception e) {

                    e.printStackTrace();

                }

            }

        });

    }


}

注意:回调可能是在主线程,也可能是子线程,因此需要注意线程切换问题。同时,在RnHostActivity还是pause状态时,callback无法回调,因此需要用Handler进行异步延时处理。

总结,这样的RN集成方案除了RN技术本身带来的优势以外还有下面几个优点:1,通过页面路由功能实现页面级的代码解耦;2,可以灵活替换APP内部的页面实现(比如切换成H5或RN实现,跳转到维护页面);3,整个RN的替换工作可以逐步展开。

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

推荐阅读更多精彩内容