引言
housing.com/apps去年我们发布了PWA网站, 用于在慢网速和高延迟的网络环境提升用户体验。
这是我们追求高效产品的第一步。我们收到很多来自社区和用户的积极反馈,打算提升我们移动产品的体验。
理想的移动产品应该是移动web的扩展,而不是替代。
挑战
-
我们的产品面向三个平台: Android, iOS 以及 web(桌面和移动端)
-
这意味着业务代码逻辑分散在4个代码库中, 有点不符合DRY原则
-
另外当引入新功能时,需要修改4个代码库的代码,无法扩展,平台功能也会很快无法同步
-
最后我们还要建立和扩充对应3个团队开发者。
目标
为了克服这些挑战,我们决定尝试使用新兴的现代JS技术栈构建跨平台的原生应用。我们根据以下目标实现了应用。
-
尽管应用采用js编写,和原生应用相比,在用户体验和响应速度上不能妥协。 简言之,如果你是用户,应用应该和app store和play store上面的原生应用体验一样。
-
应用代码应该符合DRY原则,在Android和iOS之间尽可能保持代码重用。 另外代码维护会变得很容易,新增、修改、删除功能可以尽可能最小化修改文件。
-
最后,团队web工程师应当对使用技术栈很熟悉,特定平台的原生开发工程师应该减少,这也符合Housing的BUS原则
技术栈
- react-navigation 仍处在早期开发阶段,使用声名式方式和动画API,解决了备受争议的导航问题。
由于是纯JS实现方案,和基于redux的状态管理方案搭配良好。但是,我们也在调研其他的原生和混合导航方案。
JS生态系统仍在探索异步状态管理的最佳方案,但最后肯定不只是解决各自的问题。我们使用redux-observable, 因为它可以很好隔离副作用,
并且使用强大的Rxjs操作符处理异步。这个方案也允许我们通过独立方式测试副作用处理代码
由于reducers中频繁变化,导致我们面临很多恶心的问题,并且在之前的平台上查找BUG很困难。
为了最终缓解这个问题,我们决定在整个应用中使用不可变数据。
我们通过自定义reducer 工厂在不可变数据和纯JS数据结构之间进行转换。
我们一致采取函数式和声名式的编程范式,尽可能通纯函数处理大部分的业务逻辑。
基于这中考虑,Ramda成为我们不可替代的选项。
和web应用不同,原生应用自带离线模式和持久状态支持。
这个库和redux-persist-migrate配合,在引入后端异步存储层后完美解决web应用的问题。
工具链
使用工具链包括一般的工具, yarn, prettier, eslint和husky, 还有跨平台统一的样式指南
样式指南我们也使用了下面的工具:
为开发独立原生组件提供良好支持。我们可以一对一根据设计指南直接编写UI组件。
我们正考虑内部部署,让我们的设计师能够直接访问查看UI组件。
这是RN应用亮点之一。 我们使用codepush来默默向用户发布线上更新,它能够完全控制版本和更新进度。
用来管理不同的环境(预发、开发、生产),fastlane可以轻而易举的实现自动化构建。
我们使用内部Jenkins上暴露了参数化构建面板,上面管理一切任务,如:应用秘钥、代码签名、Test Flight 和
Crashlytics Beta上传,注册内部测试构建设备、通过codepush发布在线更新等等。
搭建组合提供了很棒的测试平台。由于需要为原生模块编写mock脚本,Jest在设置RN方面显得有点笨重,但这些都挺值得。
后面sentry.io的人实现对RN应用的头等支持。
新的SDK丰富了不同设备特定数据的错误报告,提供涵盖原生和js堆栈的完整错误报告。
在不降低性能和质量的情况下,超过90%的应用源码都用JS编写。
学习知识点
RN是一个相对年轻的平台, 社区一直致力于最佳实践和正确应用方式。
官方文档仍然是我们遇到的最好学习资源。下面是我们一直在学的知识:
- InteractionManager
当需要处理性能问题时,IM将会有最佳伙伴。由于JS的单线程特性,社区已经实现最大努力將资源消耗部分转移到原生线程执行。
如果你需要在JS中处理资源消耗处理,而不影响动画、过渡和用户交互性能。
IM提供一项调度API能够在动画、过渡和用户交互完成之后延迟执行耗时处理。
- requestAnimationFrame
这个是参考web实现相同功能的API。 一个特定场景是安卓设备的涟漪效果。一般的实现是使用TouchableNativeFeedback作为onPress回调,但并不总是有用。
有时你可能看不到涟漪效果。如果你把onPress处理函数放到requestAnimationFrame回调内,你会看到动画完美呈现。
- MessageQueue
RN通过桥实现JS和原生平台通信。由于桥的持续通信,如果处理不当,会相反影响应用性能。
消息队列的spy方法,正如名称所起的,可以让你监听桥的通信细节,帮助理解通信内容和提升性能。
MessageQueue.spy(true)
- setNativeProps
从官方文档的解释,setNativeProps等同于直接向DOM设置属性。有时你想处理底层原生视图简化react渲染循环。
由于不是很成熟,我们也只在特定地方使用。建议你不要用或小心使用。
- Structuring
一开始,我们的代码仓库采用相对简单组织方式。我们从状态视图中分离处理纯UI组件。
我们发现很多分散的副作用生成代码让代码性能和测试方面形成瓶颈。
我们使用的redux-observable在消除这些障碍上有点用户。可以看下面的例子:
export default function localitySelect(action$, store, { ajax }) {
return action$
.ofType('LOCALITY_AUTOCOMPLETE')
.debounceTime(150)
.distinctUntilChanged()
.switchMap(({ payload: { text, cursor } }) => {
return ajax
.getJSON(
`${api.searchSuggest}&cursor=${cursor}&string=${text}`
)
.retry(3)
.map(({ response }) => ({
type: 'LOCALITY_SUGGEST',
payload: { data: response }
}))
.catch(error =>
Observable.of({
type: 'LOCALITY_SUGGEST',
payload: { error },
error: true
})
)
})
}
我们把所有副作用代码包装在一个函数中,而不是放在丑陋的生命周期钩子函数。
另外我们在函数内部注入ajax,这里可以在测试环境中被网络请求模拟代码替代。
- redux中间件
由于整个应用状态包括导航都由redux维持, redux中间件成为响应action执行代码不可或缺的部分。
我们在应用中把统计(屏幕检测)、日志、错误上报、修改设备状态和内存管理都代理到中间件里。
中间件可以独立单独界面运行,保持精简界面逻辑。
下面是iOS上根据当前屏幕切换黑白状态条的例子:
const statusBarMiddleware = ({ getState }) => next => (action) => {
if (!Object.values(NavigationActions).includes(action.type)) {
return next(action)
}
const currentScreen = getCurrentRouteName(getState().rootNavigation)
const result = next(action)
const nextScreen = getCurrentRouteName(getState().rootNavigation)
if (nextScreen !== currentScreen && Platform.OS === 'ios') {
setStyleForRoute(nextScreen)
}
return result
}
构建流程
构建流程官方文档提供API和平台的完整介绍。最终,你需要部署你的应用,还有维护多个测试和预发环境,统一集成不同的证书,生成发布说明和通知相关人员(产品经理、测试和设计
师)。我们尝试了很多方法后,选择Fastlane来自动化整个流程。
下面是用来iOS平台删减的beta发布流程:
desc "Submit a new Beta Build to Crashlytics"
lane :beta do |options|
automatic_code_signing(
path: "housing.xcodeproj",
use_automatic_signing: true
)
register_devices(devices_file: "./devices.txt")
match(
type: "development",
force_for_new_devices: true
)
humanable_build_number(update: true)
gym(
scheme: "housing",
clean: true
)
crashlytics(
api_token: "XXXXXXXX",
build_secret: "XXXXXXXX",
crashlytics_path: "./Pods/Crashlytics",
emails: user_email,
groups: "coders,qa",
notes: options && options[:notes] ? options[:notes]
: "Branch #{git_branch} built by #{user_email}\n#{changelog_from_git_commits(
commits_count: sh("git cherry beta | wc -l").to_i,
date_format: "short",
merge_commit_filtering: "exclude_merges"
)}"
)
release(
bundle_identifier: "XXXXXXX",
sentry_organisation: "housing",
sentry_app_name: "housing-app-staging",
deployment_name: "Staging",
target_version: "1.0"
)
slack(
slack_url: "https://hooks.slack.com/services/XXXXXXXXXX",
payload: {
"Build Number" => humanable_build_number,
"Built By" => user_email
}
)
add_git_tag(
grouping: "ios",
prefix: "v",
build_number: humanable_build_number
)
end
上面的代码处理代码签名、注册测试设备、递增构建版本号、应用构建、上传至Crashlytics Beta、生成发布说明、
根据codepush发布版本并上传sourcemap到sentry中、周知slack频道,最后在github中添加发布标签。
理论上你可以实现任何构建。上面是整个应用代码中的一部分。
由于CI会在构建之前拉取仓库最新代码,无需暂停CI就可以十分容易地修改构建流程。
专业建议
-
阅读官方的发布说明和文档
-
当你安装了包,却没生效时,执行 yarn start --reset-cache
-
使用根据官方调试器做的的react-native-debugger应用进行调试,
里面还有React Inspector 和 Redux DevTools
-
集成Perf Monitor,随时观测性能
-
总是在真机测试
-
懂React是第一要务
译者注
-
因译者水平有限,如有错误,欢迎留言指正交流
网友评论