泰然城(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¶ms=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的替换工作可以逐步展开。