网络编程:从请求到异常处理的全面指南
1. JavaScript 中的 fetch() 方法
在 JavaScript 里,
fetch()
是发起 HTTP 请求的标准方式。它接收两个参数:第一个是资源的 URI,第二个是一个对象,可用于进一步定制请求,比如更改查询方法、添加 HTTP 头、在请求中附加 HTTP 主体等。
fetch()
的返回值是一个 Promise 对象。以下是一个简单示例:
let rsp = await fetch('https://holmeshe.me/05apps/feeds');
let feeds = await rsp.json();
console.log(feeds);
这里,我们从远程获取了订阅源。需要注意的是,在 JavaScript 中,
response.json()
也是一个异步操作。虽然从表面上看,
fetch()
像是纯 JavaScript 代码,但实际上它底层是原生代码在起作用。不过这并不奇怪,在其他 React Native 组件中我们也多次看到这种模式。而且,只要平台支持,我们可以免费升级到所有现代网络改进(如 HTTP 2.0)。
2. 案例研究:将所有数据迁移到线上
以 Manyface 应用为例,我们有以下需求:
1. 所有订阅源的 API 位于
https://holmeshe.me/05apps/feeds
。
2. 当第一个屏幕(即订阅源)加载时,显示一个加载页面。
3. 用户下拉时,重新加载数据。
为了支持网络获取,我们对 Moment 组件进行了全面改造:
-
添加状态到组件
:
class Moment extends React.Component {
constructor() {
super();
this.pullDownPos = new Animated.Value(0);
this.autoScrolling = new Animated.Value(0);
this.userPulling = new Animated.Value(0);
this.scrollViewRef = undefined;
this.feedRefs = [];
this.state = {
loading: false,
data: []
}
}
}
这里,我们在构造函数中为订阅源添加了一个状态。由于现在是从远程 API 获取数据,所以这个状态会影响 UI。
-
在 Moment 中获取订阅源列表
:
async loadData() {
try {
let rsp = await fetch('https://holmeshe.me/05apps/feeds',
__DEV__ ? {
headers: {
'Cache-Control': 'no-cache'
} : undefined
});
let feeds = await rsp.json();
let feedsModel = feeds.map((obj) => {
return new FeedModel(obj);
});
this.setState({data: feedsModel});
} catch(e) {
// 目前不做任何处理
}
}
此方法用于获取订阅源列表,添加了
async
注解,以便可以使用
await
。在调试时,我们为 HTTP 头添加了
no-cache
以弃用陈旧的缓存条目。获取完成后,调用
setState()
更新 UI。
-
使用
loadData()
方法
:
componentDidMount() {
this.loadData();
}
endDrag = async (evt) => {
this.userPulling.setValue(0);
this.autoScrolling.setValue(1);
if (
evt.nativeEvent.contentOffset.y < -loadingIndicatorOffset
) {
this.setState({loading: true});
await this.loadData();
setTimeout(() => {
this.scrollViewRef.scrollToIndex({
index: 0,
animated: true
});
}, 1000);
}
}
在
componentDidMount()
生命周期回调中调用
loadData()
进行首次获取。在
endDrag()
中也调用
loadData()
,此获取是由用户下拉操作触发的。注意,
endDrag()
现在标记为
async
,因为语义从
loadData()
传递下来了。获取完成后,通过
await
实现回弹效果。
-
更改条件渲染
:
render() {
if (this.state.data.length === 0) {
return <Skeleton style={{flex: 1}}/>
}
return (
<View style={{flex: 1}}>
<Animated.FlatList
data={this.state.data}
renderItem={this.renderItem}
onViewableItemsChanged={this.onViewableItemsChanged}
contentInset={{
top: this.state.loading ? 5: 0
}}
scrollEventThrottle={1}
onScroll={
Animated.event([{
nativeEvent: {
contentOffset: { y: this.pullDownPos }
}
}], { useNativeDriver: true })
}
onScrollBeginDrag={this.beginDrag}
onScrollEndDrag={this.endDrag}
ref={this.getScrollViewRef}
onMomentumScrollEnd={this.onReset}
/>
<View style={styles.overlay}>
<LoomingSpinningEnvelope
color={'#6291f0'}
size={45}
style={{
opacity:
Animated.add(
Animated.multiply(
this.userPulling,
this.pullDownPos.interpolate({
inputRange: [-loadingIndicatorOffset, 0],
outputRange: [0.5, 0]
})
),
Animated.multiply(
this.autoScrolling,
this.pullDownPos.interpolate({
inputRange: [-loadingIndicatorOffset, 0],
outputRange: [1, 0]
})
),
)
}}
/>
</View>
</View>
);
}
当数据为空时,返回骨架视图;否则返回正常的订阅源列表 UI。需要注意的是,从在线获取数据并更新 UI 的操作也称为副作用。一般不建议在组件的构造函数中引入副作用,因为可能会导致竞态条件,在网络响应比
componentDidMount()
快的情况下,
setState()
将无效,这是一种反模式。
3. 原生层的网络编程
在了解了如何使用
fetch()
获取网络资源后,我们还需要考虑两个与用户体验相关的关键问题:
1. 应用离线时怎么办?
2. 应用冷启动时,能否显示本地缓存内容而不是等待初始往返时间?
本地缓存是解决这两个问题的关键。在关键情况下,我们可以直接从本地缓存读取数据。不过,由于我们仅使用
fetch()
获取订阅源列表,多媒体(如视频和图形)是通过各自的组件在特定逻辑流程中请求和渲染的,没有直接使用 HTTP 获取,所以需要为它们分别考虑不同的离线策略:
| 内容类型 | 离线策略 |
| ---- | ---- |
| 订阅源列表 | HTTP 缓存 |
| 图片 | 利用
react-native-fast-image
(Fast Image),这是目前最好的 React Native 图片缓存系统,分别基于 iOS 的 SDWebImage 和 Android 的 Glide |
| 视频 | 增强上一章创建的
VideoViewManager
,使其具备下载功能 |
3.1 案例研究:启用本地缓存
我们有以下需求:
1. 应用离线启动时,显示上次获取的用户内容,而不是空白骨架视图。
2. 用户联网冷启动时,在新获取数据期间,显示上次获取的用户内容,而不是空白骨架视图。
-
图片缓存
:
首先,安装 Fast Image:
npm i react-native-fast-image
./pod install
然后,将所有
<Image>
替换为
<FastImage>
。由于我们在
LoomingImage
组件中对原始
<Image>
进行了很好的封装,所以只需修改这里就能为订阅源和头像的所有图片内容启用离线缓存:
const AnimatedFastImage = Animated.createAnimatedComponent(FastImage);
class LoomingImage extends React.Component {
constructor() {
super();
this.opacity = new Animated.Value(0);
this.state = {loaded: false};
}
render() {
return (
<View style={[{
...this.props.style
}, {
backgroundColor
}]}>
{this.state.loaded === false &&
<View style={styles.overlay}>
<RotatingCircle size={28}/>
</View>
}
<AnimatedFastImage
style={{
width: '100%',
height: '100%',
opacity: this.opacity
}}
source={this.props.source}
onLoad={this.onLoad.bind(this)}
/>
</View>
);
}
onLoad() {
this.setState({loaded: true});
Animated.timing(this.opacity, {
toValue: 1,
duration: 300,
useNativeDriver: true
}).start();
}
}
这里,我们使用
Animated.createAnimatedComponent()
为组件启用动画高阶组件,将原始
<Image>
替换为
<FastImage>
,并添加了缓存策略。需要注意的是,通过 Xcode 运行代码时,可能无法离线浏览订阅源列表,这是因为开发时 HTTP 缓存控制设置为
no-cache
,移除该缓存控制即可看到正确行为。
- 视频缓存 :
class VideoView: UIView {
@objc(setSrc:)
func setSrc(_ src: String) {
guard let doc = FileManager.default.urls(
for: .cachesDirectory, in: .userDomainMask
).last else {
print("doc is nil in setSrc")
self.setup(src)
return
}
guard let url = URL.init(string: src) else {
print("Parsed url is nil for \(src) in setSrc")
self.setup(src)
return
}
let dest = doc.appendingPathComponent(
url.lastPathComponent)
if FileManager.default.fileExists(
atPath: dest.relativePath
) {
self.setup(dest.absoluteString)
} else {
self.setup(src)
self.download(url, dest, {dest in
}, {dest in
print(dest)
})
}
}
private func setup(_ src: String) {
do {
guard let url = URL.init(string: src) else {
throw VideoViewManagerError.runtimeError("url is nil in VideoView::setSrc()")
}
if player == nil {
player = AVPlayer(url: url)
if (playerLayer != nil) {
throw VideoViewManagerError.runtimeError("playerLayer is not nil while player is in VideoView::setSrc()")
}
playerLayer = AVPlayerLayer(player: player)
playerLayer!.masksToBounds = true
self.layer.addSublayer(playerLayer!)
} else {
if (playerLayer == nil) {
throw VideoViewManagerError.runtimeError("playerLayer is nil in VideoView::setSrc()")
}
playerLayer?.removeFromSuperlayer()
player = AVPlayer(url: url)
playerLayer = AVPlayerLayer(player: player)
playerLayer!.masksToBounds = true
self.layer.addSublayer(playerLayer!)
}
} catch {
self.throwToJS(error)
}
}
private func download(
_ src: URL,
_ dest: URL,
_ onSuccess: @escaping (_ dest: String) -> Void,
_ onError: @escaping (_ desc: String) -> Void)
{
let request = URLRequest.init(url: src)
let session = URLSession.init(configuration: URLSessionConfiguration.default)
let task = session.downloadTask(with: request) {(source: URL?, _, error: Error?) -> Void in
guard error == nil, let source = source else {
onError("Download failed with error:\(String(describing: error)) source:\(String(describing: source))")
return
}
do {
try FileManager.default.copyItem(at: source, to: dest)
} catch (let error) {
onError("Download failed when copying file: \(error)")
return
}
onSuccess(dest.relativePath)
}
task.resume()
}
}
这个下载模块虽然可以正常工作,但不是最优解决方案。对于工业级视频下载服务,我们需要采用更高级的流技术,如 HLS 或 Dash。
4. 异常处理
在网络操作中,我们仍然遵循之前讨论的隔离策略。该策略围绕错误边界展开,将错误处理代码集中在可以做出决策的关键点上,这些关键点通常也是组件中进入子模块逻辑的入口点。由于网络引入了大量复杂性,这次的难度比之前更高。
4.1 范围分析
我们有两个网络请求来源:一是从 JavaScript 层发送的获取订阅源列表的请求,二是从原生层发送的获取多媒体内容(图片和视频)的请求。由于图片和视频下载不属于关键路径,即使缺少一两个图片或某个订阅源的视频无法加载,Manyface 仍能继续提供用户体验,只是会有所降级,所以我们认为当前原生层的异常处理流程已经足够。
4.2 网络连接性考虑
当用户设备离线时,Manyface 应该如何响应呢?有以下几种潜在设计选项:
1.
将离线状态视为普通异常,给用户一个默认错误页面
:这种方式不太理想,因为移动网络本质上是间歇性的,我们不希望离线状态产生太大影响。而且我们已经实现了本地缓存来处理这种情况,所以错误页面就更不合适了。
2.
始终检查网络状态,仅在连接有保障时启动
fetch()
:可以使用
react-native-netinfo
(Net Info)来实现。但这实际上是一种反模式,因为我们检查连接状态的时刻比调用
fetch()
早了几毫秒,在此期间外部状态可能会发生变化,导致检查结果无关紧要。
3.
不将 Net Info 和网络获取耦合
:使用 Net Info 仅触发一个 UI 提示给用户。网络获取在离线时会抛出异常,但我们可以在内部捕获该异常,并在网络恢复后重试相同的请求。这似乎是目前最好的选择。
4.3 请求超时处理
请求超时可能是由于网络连接弱或服务器过载无法及时响应导致的。在这种情况下,重试仍然是一个可行的选择。但由于服务器可能已经处于高负载状态,大量重试可能会使问题恶化,所以我们需要减缓重试速度并设置重试次数限制。
4.4 HTTP 状态码处理
对于异常的 HTTP 状态码,我们是否应该重试呢?这要分情况。只有代表可恢复错误的特定代码才需要重试,例如 429(请求过多)、500(内部服务器错误)和其他 5xx 状态码。而对于 400(错误请求)或 401(未授权)等不可恢复的异常代码,我们将其转换为异常并抛给错误边界,由错误边界对异常的 UI 和逻辑做出最终决策。
4.5 案例研究:强化网络组件
我们将上述讨论总结为以下需求:
1. 离线获取时,保存请求,网络恢复后重试。
2. 请求超时(3 秒)、401 和 5xx 状态码时,最多重试三次。第一次重试在 3 秒后,第二次在 10 秒后,第三次在 30 秒后。如果 30 秒后所有重试都失败,则抛出异常。
3. 对于上述未列出的 HTTP 代码,抛出代表错误的异常。
首先,安装 Net Info 包:
npm install --save @react-native-community/netinfo
./pod install
然后,创建一个封装了原生
fetch()
并包含重试逻辑的服务:
export default class NetWorkService {
retryTimes = 0;
retryIntervals = [0, 3, 10, 30];
async robustFetch(url, config) {
await this.throttle();
try {
const controller = new AbortController();
let timer;
let timeout = new Promise((resolve, reject) => {
timer = setTimeout(() => {
controller.abort();
reject({message: 'Timed out'});
}, 3000);
});
const response = await Promise.race([fetch(url, {
...config,
signal: controller.signal
}), timeout]);
if (!!timer) {
clearTimeout(timer);
}
if (!response.ok) {
if (response.status === 401 || response.status > 500) {
return this.robustFetch(url, config);
}
reject({message: 'Netowrk failed with HTTP code:' + response.status});
}
return response;
} catch (e) {
if (e?.message?.includes?.('Network request failed')) {
await this.waitForNetwork()
return this.robustFetch(url, config);
}
if (e?.message?.includes?.('Timed out')) {
return this.robustFetch(url, config);
}
throw('Unkown network issue');
}
}
async throttle() {
return new Promise((resolve, reject) => {
if (this.retryTimes >= this.retryIntervals.length) {
reject('Network Failed After 3 Retries');
}
const interval = this.retryIntervals[this.retryTimes++];
setTimeout(resolve, interval * 1000);
});
}
async waitForNetwork() {
return new Promise((resolve, reject) => {
const unsubscribe = NetInfo.addEventListener(state => {
if (state.isConnected) {
unsubscribe();
resolve();
}
});
});
}
}
最后,修改 Moment 组件以连接异常处理流程:
async loadData() {
try {
let service = new NetWorkService();
let rsp = await
service.robustFetch(
'https://holmeshe.me/05apps/feeds',
true ? {
headers: {
'Cache-Control': 'no-cache'
}
} : undefined
);
let feeds = await rsp.json();
let feedsModel = feeds.map((obj) => { return new FeedModel(obj); });
this.setState({data: feedsModel});
} catch(e) {
setState(() => { throw e });
}
}
通过将原生
fetch()
替换为
robustFetch()
,并在
setState()
中抛出相同的异常,异常可以被定义良好的错误边界捕获,由错误边界对所有异常的 UI 和行为做出最终决策。
综上所述,通过合理使用
fetch()
、启用本地缓存和完善的异常处理机制,我们可以提高 Manyface 应用的网络性能和用户体验,使其在各种网络条件下都能稳定运行。
网络编程:从请求到异常处理的全面指南
5. 总结与展望
在前面的内容中,我们详细探讨了网络编程的多个方面,包括使用
fetch()
进行网络请求、将数据迁移到线上、启用本地缓存以及异常处理等。这些技术的应用能够显著提升应用的性能和用户体验,特别是在网络环境不稳定的情况下。
5.1 关键技术回顾
-
fetch()方法 :作为 JavaScript 中发起 HTTP 请求的标准方式,fetch()提供了强大的功能和灵活性。通过传递 URI 和配置对象,我们可以定制请求的各个方面,并且其返回的 Promise 对象使得异步操作的处理更加方便。 -
本地缓存策略
:针对不同类型的内容(订阅源列表、图片、视频),我们采用了不同的离线策略。HTTP 缓存用于订阅源列表,
react-native-fast-image用于图片缓存,增强VideoViewManager实现视频下载功能。这些策略确保了在离线或网络不佳的情况下,应用仍能提供一定的内容展示。 -
异常处理机制
:通过遵循隔离策略,将错误处理集中在关键节点,并针对不同的网络异常情况(离线、超时、异常 HTTP 状态码)制定了相应的处理方案。使用
NetWorkService封装fetch()并添加重试逻辑,使得应用在遇到网络问题时能够更加健壮。
5.2 后续优化方向
虽然我们已经实现了较为完善的网络编程方案,但仍有一些方面可以进一步优化:
1.
视频下载优化
:目前的视频下载模块虽然可以工作,但对于工业级应用,需要采用更高级的流技术,如 HLS 或 Dash。这些技术可以提供更好的视频播放体验,特别是在网络带宽不稳定的情况下。
2.
缓存管理优化
:随着应用的使用,本地缓存可能会占用大量的存储空间。因此,需要实现缓存管理机制,定期清理过期或不再使用的缓存数据。
3.
错误日志上传
:在异常处理中,目前只是简单地打印错误日志。在生产环境中,应该将错误日志上传到服务器,以便开发人员及时发现和解决问题。
6. 实际应用建议
在实际开发中,我们可以按照以下步骤来实现一个具有良好网络性能的应用:
1.
规划网络请求
:明确应用中需要进行的网络请求,确定请求的频率和数据量。合理安排请求的时机,避免不必要的网络开销。
2.
选择合适的缓存策略
:根据不同类型的内容,选择合适的缓存策略。对于经常更新的数据,可以设置较短的缓存时间;对于不经常变化的数据,可以设置较长的缓存时间。
3.
实现异常处理
:遵循隔离策略,将错误处理集中在关键节点。对于不同的网络异常情况,制定相应的处理方案,确保应用在遇到问题时能够稳定运行。
4.
进行性能测试
:在开发过程中,进行性能测试,模拟不同的网络环境,检查应用的响应时间和稳定性。根据测试结果进行优化,确保应用在各种网络条件下都能提供良好的用户体验。
7. 流程图总结
为了更直观地展示整个网络编程的流程,我们可以使用 mermaid 流程图进行总结:
graph LR
classDef startend fill:#F5EBFF,stroke:#BE8FED,stroke-width:2px
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px
classDef decision fill:#FFF6CC,stroke:#FFBC52,stroke-width:2px
A([开始]):::startend --> B{网络请求类型}:::decision
B -->|订阅源列表| C(使用 fetch() 请求):::process
B -->|图片| D(使用 react-native-fast-image 缓存):::process
B -->|视频| E(增强 VideoViewManager 下载):::process
C --> F{请求成功?}:::decision
D --> F
E --> F
F -->|是| G(更新 UI):::process
F -->|否| H{异常类型}:::decision
H -->|离线| I(等待网络恢复并重试):::process
H -->|超时| J(设置重试间隔并重试):::process
H -->|异常 HTTP 状态码| K{可恢复?}:::decision
K -->|是| J
K -->|否| L(抛出异常到错误边界):::process
G --> M([结束]):::startend
I --> C
J --> C
L --> M
这个流程图展示了从网络请求开始,到异常处理,再到最终结果的整个过程。通过这个流程图,我们可以更清晰地理解各个环节之间的关系,以及如何根据不同的情况进行处理。
总之,网络编程是一个复杂而重要的领域,需要我们综合考虑多个方面的因素。通过合理使用各种技术和策略,我们可以开发出具有良好性能和用户体验的应用,满足用户在不同网络环境下的需求。
超级会员免费看
9万+

被折叠的 条评论
为什么被折叠?



