3
This commit is contained in:
@@ -38,6 +38,9 @@ typedef void (^KBStreamFetcherFinishHandler)(NSError *_Nullable error);
|
||||
/// 非 SSE 且一次性拿到大段文本时,是否按空格切词逐条回调(模拟“逐词流式”),默认 YES。
|
||||
@property (nonatomic, assign) BOOL splitLargeDeltasOnWhitespace;
|
||||
|
||||
/// 调试日志:默认 YES。输出起止时刻、首包耗时、各分片内容(截断)等关键信息。
|
||||
@property (nonatomic, assign) BOOL loggingEnabled;
|
||||
|
||||
// 回调(统一在主线程触发)
|
||||
@property (nonatomic, copy, nullable) KBStreamFetcherChunkHandler onChunk;
|
||||
@property (nonatomic, copy, nullable) KBStreamFetcherFinishHandler onFinish;
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
@property (nonatomic, strong) NSMutableArray<NSString *> *pendingQueue; // 待回调的分片(节流输出)
|
||||
@property (nonatomic, strong) NSTimer *flushTimer; // 定时从队列取出一条回调
|
||||
@property (nonatomic, strong, nullable) NSError *finishError; // 结束时的错误(需要等队列清空再回调)
|
||||
|
||||
// Metrics
|
||||
@property (nonatomic, assign) CFAbsoluteTime tStart; // start() 被调用的时刻
|
||||
@property (nonatomic, assign) CFAbsoluteTime tFirstByte; // 第一次拿到可解码内容
|
||||
@property (nonatomic, assign) CFAbsoluteTime tFinish; // 完成/失败时刻
|
||||
@property (nonatomic, assign) NSInteger emittedChunkCount; // 已输出分片数量
|
||||
@end
|
||||
|
||||
// 计算数据中以 UTF-8 编码可完整解码的“前缀字节长度”,避免切断多字节字符
|
||||
@@ -60,6 +66,7 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) {
|
||||
_pendingQueue = [NSMutableArray array];
|
||||
_flushInterval = 0.1;
|
||||
_splitLargeDeltasOnWhitespace = YES;
|
||||
_loggingEnabled = YES;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
@@ -96,6 +103,18 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) {
|
||||
[self.flushTimer invalidate]; self.flushTimer = nil;
|
||||
self.finishError = nil;
|
||||
|
||||
self.tStart = CFAbsoluteTimeGetCurrent();
|
||||
self.tFirstByte = 0;
|
||||
self.tFinish = 0;
|
||||
self.emittedChunkCount = 0;
|
||||
if (self.loggingEnabled) {
|
||||
NSLog(@"[KBStream] start url=%@ acceptSSE=%@ disableCompression=%@ flush=%.0fms splitWords=%@",
|
||||
self.url.absoluteString,
|
||||
self.acceptEventStream?@"YES":@"NO",
|
||||
self.disableCompression?@"YES":@"NO",
|
||||
self.flushInterval*1000.0,
|
||||
self.splitLargeDeltasOnWhitespace?@"YES":@"NO");
|
||||
}
|
||||
self.task = [self.session dataTaskWithRequest:req];
|
||||
[self.task resume];
|
||||
}
|
||||
@@ -150,6 +169,15 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) {
|
||||
NSUInteger validLen = (self.textEncoding == NSUTF8StringEncoding)
|
||||
? kb_validUTF8PrefixLen(self.buffer)
|
||||
: self.buffer.length;
|
||||
if (validLen > 0 && self.tFirstByte == 0) {
|
||||
self.tFirstByte = CFAbsoluteTimeGetCurrent();
|
||||
if (self.loggingEnabled) {
|
||||
NSLog(@"[KBStream] first-bytes after %.0fms (encoding=%@, SSE=%@)",
|
||||
(self.tFirstByte - self.tStart)*1000.0,
|
||||
(self.textEncoding==NSUTF8StringEncoding?@"UTF-8":@"Other"),
|
||||
self.isSSE?@"YES":@"NO");
|
||||
}
|
||||
}
|
||||
if (validLen == 0) return; // 末尾可能卡着半个字符
|
||||
|
||||
if (self.isSSE) {
|
||||
@@ -181,12 +209,8 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) {
|
||||
NSString *v = [ln substringFromIndex:5];
|
||||
if (v.length > 0 && [v hasPrefix:@" "]) v = [v substringFromIndex:1];
|
||||
[payload appendString:v ?: @""];
|
||||
[payload appendString:@"\n"]; // 多 data 行合并
|
||||
}
|
||||
}
|
||||
if (payload.length > 0 && [payload hasSuffix:@"\n"]) {
|
||||
[payload deleteCharactersInRange:NSMakeRange(payload.length - 1, 1)];
|
||||
}
|
||||
if (payload.length > 0) { [self enqueueChunk:payload]; }
|
||||
}
|
||||
}
|
||||
@@ -228,17 +252,28 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) {
|
||||
NSString *v = [ln substringFromIndex:5];
|
||||
if (v.length > 0 && [v hasPrefix:@" "]) v = [v substringFromIndex:1];
|
||||
[payload appendString:v ?: @""];
|
||||
[payload appendString:@"\n"];
|
||||
}
|
||||
}
|
||||
if (payload.length > 0 && [payload hasSuffix:@"\n"]) {
|
||||
[payload deleteCharactersInRange:NSMakeRange(payload.length - 1, 1)];
|
||||
}
|
||||
if (payload.length > 0) {
|
||||
[self emitChunk:payload];
|
||||
NSString *delta = nil;
|
||||
if ((NSInteger)payload.length >= self.deliveredCharCount) {
|
||||
delta = [payload substringFromIndex:self.deliveredCharCount];
|
||||
} else {
|
||||
delta = payload;
|
||||
}
|
||||
self.deliveredCharCount = payload.length;
|
||||
if (delta.length > 0) { [self emitChunk:delta]; }
|
||||
}
|
||||
}
|
||||
|
||||
self.tFinish = CFAbsoluteTimeGetCurrent();
|
||||
if (self.loggingEnabled) {
|
||||
double t0 = (self.tFirstByte>0? (self.tFirstByte - self.tStart)*1000.0 : -1);
|
||||
double t1 = (self.tFirstByte>0? (self.tFinish - self.tFirstByte)*1000.0 : -1);
|
||||
double tt = (self.tFinish - self.tStart)*1000.0;
|
||||
NSLog(@"[KBStream] finish chunks=%ld firstByte=%.0fms after start, tail=%.0fms, total=%.0fms error=%@",
|
||||
(long)self.emittedChunkCount, t0, t1, tt, error);
|
||||
}
|
||||
// 若队列还有待输出内容,等队列清空再回调 finish
|
||||
if (self.pendingQueue.count > 0) {
|
||||
self.finishError = error;
|
||||
@@ -280,6 +315,11 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) {
|
||||
text = [text stringByReplacingOccurrencesOfString:@"\t " withString:@"\t"];
|
||||
}
|
||||
if (text.length == 0) { self.lastChunkEndedWithTab = NO; return; }
|
||||
self.emittedChunkCount += 1;
|
||||
if (self.loggingEnabled) {
|
||||
NSLog(@"[KBStream] chunk#%ld len=%lu text=\"%@\"",
|
||||
(long)self.emittedChunkCount, (unsigned long)text.length, KBPrintableSnippet(text, 160));
|
||||
}
|
||||
if (self.onChunk) dispatch_async(dispatch_get_main_queue(), ^{ self.onChunk(text); });
|
||||
self.hasEmitted = YES;
|
||||
// 记录末尾是否为分段分隔符 \t(用于跨分片处理)
|
||||
@@ -317,4 +357,15 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) {
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - Logging helpers
|
||||
|
||||
static NSString *KBPrintableSnippet(NSString *s, NSUInteger maxLen) {
|
||||
if (!s) return @"";
|
||||
NSString *x = [s stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"];
|
||||
if (x.length > maxLen) {
|
||||
x = [[x substringToIndex:maxLen] stringByAppendingString:@"…"];
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -21,7 +21,9 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
- (void)setItems:(NSArray<NSString *> *)items;
|
||||
|
||||
/// 在指定 index 上显示/隐藏加载指示(若 cell 不可见,内部会记录状态,待出现时应用)
|
||||
- (void)setLoading:(BOOL)loading atIndex:(NSInteger)index;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ static NSString * const kKBFunctionTagCellId2 = @"KBFunctionTagCellId2";
|
||||
@interface KBFunctionTagListView () <UICollectionViewDataSource, UICollectionViewDelegateFlowLayout>
|
||||
@property (nonatomic, strong) UICollectionView *collectionViewInternal;
|
||||
@property (nonatomic, copy) NSArray<NSString *> *items;
|
||||
@property (nonatomic, strong) NSMutableSet<NSNumber *> *loadingIndexes; // 记录需要展示loading的index
|
||||
@end
|
||||
|
||||
@implementation KBFunctionTagListView
|
||||
@@ -31,6 +32,7 @@ static NSString * const kKBFunctionTagCellId2 = @"KBFunctionTagCellId2";
|
||||
[_collectionViewInternal.trailingAnchor constraintEqualToAnchor:self.trailingAnchor],
|
||||
]];
|
||||
_items = @[];
|
||||
_loadingIndexes = [NSMutableSet set];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
@@ -46,6 +48,8 @@ static NSString * const kKBFunctionTagCellId2 = @"KBFunctionTagCellId2";
|
||||
- (__kindof UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
|
||||
KBFunctionTagCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:kKBFunctionTagCellId2 forIndexPath:indexPath];
|
||||
cell.titleLabel.text = (indexPath.item < self.items.count) ? self.items[indexPath.item] : @"";
|
||||
BOOL loading = [self.loadingIndexes containsObject:@(indexPath.item)];
|
||||
[cell setLoading:loading];
|
||||
return cell;
|
||||
}
|
||||
|
||||
@@ -65,4 +69,15 @@ static NSString * const kKBFunctionTagCellId2 = @"KBFunctionTagCellId2";
|
||||
}
|
||||
}
|
||||
|
||||
- (void)setLoading:(BOOL)loading atIndex:(NSInteger)index {
|
||||
NSNumber *key = @(index);
|
||||
if (loading) { [self.loadingIndexes addObject:key]; }
|
||||
else { [self.loadingIndexes removeObject:key]; }
|
||||
NSIndexPath *ip = [NSIndexPath indexPathForItem:index inSection:0];
|
||||
if (ip && ip.item < [self.collectionViewInternal numberOfItemsInSection:0]) {
|
||||
KBFunctionTagCell *cell = (KBFunctionTagCell *)[self.collectionViewInternal cellForItemAtIndexPath:ip];
|
||||
if ([cell isKindOfClass:[KBFunctionTagCell class]]) { [cell setLoading:loading]; }
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -17,7 +17,9 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
/// 头像/图标
|
||||
@property (nonatomic, strong, readonly) UIImageView *iconView;
|
||||
|
||||
/// 显示/隐藏加载指示(小菊花)
|
||||
- (void)setLoading:(BOOL)loading;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
@interface KBFunctionTagCell ()
|
||||
@property (nonatomic, strong) UILabel *titleLabelInternal;
|
||||
@property (nonatomic, strong) UIImageView *iconViewInternal;
|
||||
@property (nonatomic, strong) UIActivityIndicatorView *loadingView;
|
||||
@end
|
||||
|
||||
@implementation KBFunctionTagCell
|
||||
@@ -34,6 +35,14 @@
|
||||
make.right.equalTo(self.contentView.mas_right).offset(-10);
|
||||
make.centerY.equalTo(self.contentView.mas_centerY);
|
||||
}];
|
||||
|
||||
// 小菊花:默认隐藏,放在标题右侧
|
||||
[self.contentView addSubview:self.loadingView];
|
||||
[self.loadingView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerY.equalTo(self.contentView);
|
||||
make.right.equalTo(self.contentView.mas_right).offset(-10);
|
||||
make.width.height.mas_equalTo(16);
|
||||
}];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
@@ -64,10 +73,35 @@
|
||||
return _titleLabelInternal;
|
||||
}
|
||||
|
||||
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
|
||||
static UIActivityIndicatorViewStyle KBSpinnerStyle(void) { return UIActivityIndicatorViewStyleMedium; }
|
||||
#else
|
||||
static UIActivityIndicatorViewStyle KBSpinnerStyle(void) { return UIActivityIndicatorViewStyleGray; }
|
||||
#endif
|
||||
|
||||
- (UIActivityIndicatorView *)loadingView {
|
||||
if (!_loadingView) {
|
||||
_loadingView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:KBSpinnerStyle()];
|
||||
_loadingView.hidesWhenStopped = YES;
|
||||
_loadingView.color = [UIColor grayColor];
|
||||
_loadingView.hidden = YES;
|
||||
}
|
||||
return _loadingView;
|
||||
}
|
||||
|
||||
#pragma mark - Expose
|
||||
|
||||
- (UILabel *)titleLabel { return self.titleLabelInternal; }
|
||||
- (UIImageView *)iconView { return self.iconViewInternal; }
|
||||
|
||||
@end
|
||||
- (void)setLoading:(BOOL)loading {
|
||||
if (loading) {
|
||||
self.loadingView.hidden = NO;
|
||||
[self.loadingView startAnimating];
|
||||
} else {
|
||||
[self.loadingView stopAnimating];
|
||||
self.loadingView.hidden = YES;
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -40,6 +40,8 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
|
||||
// 网络流式(封装)
|
||||
@property (nonatomic, strong, nullable) KBStreamFetcher *streamFetcher;
|
||||
@property (nonatomic, assign) BOOL streamHasOutput; // 是否已输出过正文(首段去首个 \t 用)
|
||||
@property (nonatomic, strong, nullable) NSNumber *loadingTagIndex; // 当前显示loading的标签index
|
||||
@property (nonatomic, copy, nullable) NSString *loadingTagTitle;
|
||||
|
||||
// Data
|
||||
@property (nonatomic, strong) NSArray<NSString *> *itemsInternal;
|
||||
@@ -212,8 +214,7 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
|
||||
}];
|
||||
self.streamOverlay = overlay;
|
||||
|
||||
// 优先拉取后端测试数据(GET);失败则回落到本地演示
|
||||
[self kb_startNetworkStreamingWithSeed:title];
|
||||
// 只创建 UI;网络在点击 cell 时启动,避免重复 start 导致首包重复
|
||||
}
|
||||
|
||||
- (void)kb_onTapStreamDelete {
|
||||
@@ -258,8 +259,13 @@ static NSString * const kKBStreamDemoURL = @"http://192.168.1.144:7529/api/demo/
|
||||
};
|
||||
fetcher.onFinish = ^(NSError * _Nullable error) {
|
||||
__strong typeof(weakSelf) self = weakSelf; if (!self) return;
|
||||
// 若还未出现任何数据且仍在 loading,则取消小菊花
|
||||
if (!self.streamHasOutput && self.loadingTagIndex) {
|
||||
[self.tagListView setLoading:NO atIndex:self.loadingTagIndex.integerValue];
|
||||
self.loadingTagIndex = nil; self.loadingTagTitle = nil;
|
||||
}
|
||||
if (error) { [KBHUD showInfo:@"拉取失败"]; }
|
||||
[self.streamOverlay finish];
|
||||
if (self.streamOverlay) { [self.streamOverlay finish]; }
|
||||
};
|
||||
self.streamFetcher = fetcher;
|
||||
[self.streamFetcher start];
|
||||
@@ -277,7 +283,16 @@ static NSString * const kKBStreamDemoURL = @"http://192.168.1.144:7529/api/demo/
|
||||
/// - 目前网络层(KBStreamFetcher)已做 “/t->\t、首段去一个 \t、段间去一个空格”
|
||||
/// - 这里仅负责附加到视图与标记首段状态,避免 UI 抖动
|
||||
- (void)kb_appendChunkToStreamView:(NSString *)chunk {
|
||||
if (chunk.length == 0 || !self.streamOverlay) return;
|
||||
if (chunk.length == 0) return;
|
||||
// 第一次有数据才创建 overlay,并取消 cell 上的小菊花
|
||||
if (!self.streamOverlay) {
|
||||
[self kb_showStreamTextViewIfNeededWithTitle:self.loadingTagTitle ?: @""];
|
||||
if (self.loadingTagIndex) {
|
||||
[self.tagListView setLoading:NO atIndex:self.loadingTagIndex.integerValue];
|
||||
self.loadingTagIndex = nil; self.loadingTagTitle = nil;
|
||||
}
|
||||
}
|
||||
if (!self.streamOverlay) return;
|
||||
[self.streamOverlay appendChunk:chunk];
|
||||
self.streamHasOutput = YES;
|
||||
}
|
||||
@@ -287,10 +302,14 @@ static NSString * const kKBStreamDemoURL = @"http://192.168.1.144:7529/api/demo/
|
||||
- (void)tagListView:(KBFunctionTagListView *)view didSelectIndex:(NSInteger)index title:(NSString *)title {
|
||||
// 权限全部打开(键盘已启用 + 完全访问)。在扩展进程中仅需判断“完全访问”。
|
||||
if ([[KBFullAccessManager shared] hasFullAccess]) {
|
||||
[self kb_showStreamTextViewIfNeededWithTitle:title ?: @""];
|
||||
// 先在 cell 上显示小菊花,等有数据回来再弹出 overlay
|
||||
[self.tagListView setLoading:YES atIndex:index];
|
||||
self.loadingTagIndex = @(index);
|
||||
self.loadingTagTitle = title ?: @"";
|
||||
[self kb_startNetworkStreamingWithSeed:self.loadingTagTitle];
|
||||
return;
|
||||
}
|
||||
// 保留原有拉起主 App 的逻辑(若需要)
|
||||
// 未开启完全访问:保持原有引导路径
|
||||
[KBHUD showInfo:@"处理中…"];
|
||||
UIInputViewController *ivc = KBFindInputViewController(self);
|
||||
if (!ivc) return;
|
||||
|
||||
Reference in New Issue
Block a user