ReadViewSDK/Doc/WXRead/decompiled/WRPageViewController.m

603 lines
25 KiB
Objective-C

//
// WRPageViewController.m
// WeRead (读书)
//
// Detailed pseudo-code reconstruction of the page view controller.
// Wraps UIPageViewController with fault detection and patching for
// known Apple bugs in the page curl and scroll transition styles.
//
#import "WRPageViewController.h"
#import <objc/runtime.h>
// ============================================================================
#pragma mark - Constants
// ============================================================================
/// Notification posted when a page transition completes.
static NSString *const kWRPageDidTransitionNotification =
@"WRPageViewControllerDidTransition";
/// UserDefaults key to track whether the nav-direction fault has been patched.
static NSString *const kNavDirectionPatchedKey =
@"WRPageVC_NavDirectionPatched";
// ============================================================================
#pragma mark - Private Forward Declarations
// ============================================================================
@interface WRPageViewController () <UIPageViewControllerDelegate,
UIPageViewControllerDataSource,
UIGestureRecognizerDelegate>
@property (nonatomic, strong) UIPageViewController *uiPageViewController;
@property (nonatomic, assign) NSUInteger currentPageIndex;
@property (nonatomic, assign) BOOL isTransitioning;
/// Pending completion block for -setViewControllers:direction:animated:completion:.
@property (nonatomic, copy, nullable) void (^pendingTransitionCompletion)(BOOL);
@end
// ============================================================================
#pragma mark - Fault Patching State
// ============================================================================
/// Static flag: whether the navigation direction fault has been detected
/// in this process lifetime.
static BOOL s_NavDirectionFaultDetected = NO;
/// Static flag: whether the page curl fault has been patched.
static BOOL s_PageCurlFaultPatched = NO;
// ============================================================================
#pragma mark - WRPageViewController Implementation
// ============================================================================
@implementation WRPageViewController
// ============================================================================
#pragma mark - Initialization
// ============================================================================
- (instancetype)initWithDelegate:(id<WRPageViewControllerDelegate>)delegate
withPageType:(UIPageViewControllerTransitionStyle)pageType
pageFlippingStyle:(WRPageFlippingStyle)flippingStyle {
self = [super initWithNibName:nil bundle:nil];
if (self) {
_pageDelegate = delegate;
_pageFlippingStyle = flippingStyle;
_pagingEnabled = YES;
_currentPageIndex = 0;
_isTransitioning = NO;
// Determine the UIPageViewController transition style.
UIPageViewControllerTransitionStyle uiStyle;
switch (flippingStyle) {
case WRPageFlippingStyleCurl:
uiStyle = UIPageViewControllerTransitionStylePageCurl;
break;
case WRPageFlippingStyleSlide:
case WRPageFlippingStyleFade:
case WRPageFlippingStyleNone:
default:
uiStyle = UIPageViewControllerTransitionStyleScroll;
break;
}
// Create the underlying UIPageViewController.
NSDictionary *options = @{
UIPageViewControllerOptionInterPageSpacingKey: @(20.0),
};
_uiPageViewController =
[[UIPageViewController alloc] initWithTransitionStyle:uiStyle
navigationOrientation:UIPageViewControllerNavigationOrientationHorizontal
options:options];
_uiPageViewController.delegate = self;
_uiPageViewController.dataSource = self;
// Apply known fault patches proactively.
[WRPageViewController patchNavigationDirectionFault];
if (flippingStyle == WRPageFlippingStyleCurl) {
[WRPageViewController patchUIPageCurlFault];
}
}
return self;
}
- (void)viewDidLoad {
[super viewDidLoad];
// Embed the UIPageViewController as a child.
[self addChildViewController:self.uiPageViewController];
self.uiPageViewController.view.frame = self.view.bounds;
self.uiPageViewController.view.autoresizingMask =
UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
[self.view addSubview:self.uiPageViewController.view];
[self.uiPageViewController didMoveToParentViewController:self];
// Configure gesture recognizers.
[self _configureGestureRecognizers];
}
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
self.uiPageViewController.view.frame = self.view.bounds;
}
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
// ============================================================================
#pragma mark - Gesture Recognizer Configuration
// ============================================================================
/// Configures the page view controller's gesture recognizers.
/// In scroll mode, the internal UIScrollView handles paging.
/// In curl mode, the tap-to-flip gesture is added.
- (void)_configureGestureRecognizers {
if (self.pageFlippingStyle == WRPageFlippingStyleCurl) {
// Add tap zones for curl: left 1/4 and right 1/4 of the screen.
UITapGestureRecognizer *leftTap =
[[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(_handleLeftTap:)];
leftTap.delegate = self;
[self.view addGestureRecognizer:leftTap];
UITapGestureRecognizer *rightTap =
[[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(_handleRightTap:)];
rightTap.delegate = self;
[self.view addGestureRecognizer:rightTap];
}
}
- (void)_handleLeftTap:(UITapGestureRecognizer *)gr {
[self goToPreviousPageAnimated:YES];
}
- (void)_handleRightTap:(UITapGestureRecognizer *)gr {
[self goToNextPageAnimated:YES];
}
// ============================================================================
#pragma mark - Page Navigation
// ============================================================================
- (void)setCurrentPageIndex:(NSUInteger)pageIndex animated:(BOOL)animated {
if (pageIndex == _currentPageIndex) return;
UIPageViewControllerNavigationDirection direction =
(pageIndex > _currentPageIndex)
? UIPageViewControllerNavigationDirectionForward
: UIPageViewControllerNavigationDirectionReverse;
_currentPageIndex = pageIndex;
// Notify delegate.
if ([self.pageDelegate respondsToSelector:
@selector(pageViewController:didChangePageIndex:)]) {
[self.pageDelegate pageViewController:self didChangePageIndex:pageIndex];
}
}
- (BOOL)goToNextPageAnimated:(BOOL)animated {
if (!self.pagingEnabled || self.isTransitioning) return NO;
// Ask the data source for the next view controller.
UIViewController *currentVC = self.uiPageViewController.viewControllers.firstObject;
if (!currentVC) return NO;
UIViewController *nextVC =
[self.pageDataSource pageViewController:self
viewControllerAfterViewController:currentVC];
if (!nextVC) return NO; // At the end.
self.isTransitioning = YES;
__weak typeof(self) weakSelf = self;
[self.uiPageViewController setViewControllers:@[nextVC]
direction:UIPageViewControllerNavigationDirectionForward
animated:animated
completion:^(BOOL finished) {
__strong typeof(weakSelf) strongSelf = weakSelf;
strongSelf.isTransitioning = NO;
if (finished) {
strongSelf.currentPageIndex++;
}
}];
return YES;
}
- (BOOL)goToPreviousPageAnimated:(BOOL)animated {
if (!self.pagingEnabled || self.isTransitioning) return NO;
UIViewController *currentVC = self.uiPageViewController.viewControllers.firstObject;
if (!currentVC) return NO;
UIViewController *prevVC =
[self.pageDataSource pageViewController:self
viewControllerBeforeViewController:currentVC];
if (!prevVC) return NO; // At the beginning.
self.isTransitioning = YES;
__weak typeof(self) weakSelf = self;
[self.uiPageViewController setViewControllers:@[prevVC]
direction:UIPageViewControllerNavigationDirectionReverse
animated:animated
completion:^(BOOL finished) {
__strong typeof(weakSelf) strongSelf = weakSelf;
strongSelf.isTransitioning = NO;
if (finished && strongSelf.currentPageIndex > 0) {
strongSelf.currentPageIndex--;
}
}];
return YES;
}
- (void)setViewControllers:(NSArray<UIViewController *> *)viewControllers
direction:(UIPageViewControllerNavigationDirection)direction
animated:(BOOL)animated
completion:(void (^ __nullable)(BOOL finished))completion {
self.pendingTransitionCompletion = completion;
__weak typeof(self) weakSelf = self;
[self.uiPageViewController setViewControllers:viewControllers
direction:direction
animated:animated
completion:^(BOOL finished) {
__strong typeof(weakSelf) strongSelf = weakSelf;
strongSelf.isTransitioning = NO;
if (strongSelf.pendingTransitionCompletion) {
strongSelf.pendingTransitionCompletion(finished);
strongSelf.pendingTransitionCompletion = nil;
}
}];
}
// ============================================================================
#pragma mark - UIPageViewControllerDataSource
// ============================================================================
- (UIViewController *)pageViewController:(UIPageViewController *)pageViewController
viewControllerBeforeViewController:(UIViewController *)viewController {
// Delegate to our data source.
if ([self.pageDataSource respondsToSelector:
@selector(pageViewController:viewControllerBeforeViewController:)]) {
return [self.pageDataSource pageViewController:self
viewControllerBeforeViewController:viewController];
}
return nil;
}
- (UIViewController *)pageViewController:(UIPageViewController *)pageViewController
viewControllerAfterViewController:(UIViewController *)viewController {
if ([self.pageDataSource respondsToSelector:
@selector(pageViewController:viewControllerAfterViewController:)]) {
return [self.pageDataSource pageViewController:self
viewControllerAfterViewController:viewController];
}
return nil;
}
// ============================================================================
#pragma mark - UIPageViewControllerDelegate
// ============================================================================
- (void)pageViewController:(UIPageViewController *)pageViewController
willTransitionToViewControllers:(NSArray<UIViewController *> *)pendingViewControllers {
self.isTransitioning = YES;
if ([self.pageDelegate respondsToSelector:
@selector(pageViewController:willTransitionToViewControllers:)]) {
[self.pageDelegate pageViewController:self
willTransitionToViewControllers:pendingViewControllers];
}
}
- (void)pageViewController:(UIPageViewController *)pageViewController
didFinishAnimating:(BOOL)finished
previousViewControllers:(NSArray<UIViewController *> *)previousViewControllers
transitionCompleted:(BOOL)completed {
self.isTransitioning = NO;
if (completed) {
// Detect the navigation direction fault.
// This is called after every completed transition to check if
// UIPageViewController's internal state is still consistent.
[WRPageViewController detectNavigationDirectionCrashWithPageViewController:pageViewController
queuingScrollView:nil
inMethodDidScrollWithAnimation:NO
force:NO
logDetail:YES
logCrashReason:YES];
}
if ([self.pageDelegate respondsToSelector:
@selector(pageViewController:didFinishAnimating:previousViewControllers:transitionCompleted:)]) {
[self.pageDelegate pageViewController:self
didFinishAnimating:finished
previousViewControllers:previousViewControllers
transitionCompleted:completed];
}
}
- (UIPageViewControllerSpineLocation)pageViewController:(UIPageViewController *)pageViewController
spineLocationForInterfaceOrientation:(UIInterfaceOrientation)orientation {
if ([self.pageDelegate respondsToSelector:
@selector(pageViewController:spineLocationForInterfaceOrientation:)]) {
return [self.pageDelegate pageViewController:self
spineLocationForInterfaceOrientation:orientation];
}
return UIPageViewControllerSpineLocationMin;
}
// ============================================================================
#pragma mark - Fault Detection & Patching
// ============================================================================
///
/// Detects the navigation direction crash in UIPageViewController.
///
/// BACKGROUND:
/// UIPageViewController uses an internal "QueuingScrollView" (a private
/// UIScrollView subclass) to manage page transitions. During rapid swiping
/// or when the app enters the background during a transition, the internal
/// state machine can enter an inconsistent state where:
/// - The navigation direction is set to an invalid value.
/// - The queuing scroll view's contentOffset is inconsistent with the
/// current view controller set.
/// - A subsequent -scrollViewDidScroll: callback accesses deallocated
/// or nil view controllers.
///
/// This results in a crash with a message like:
/// "No view controller managing the view"
/// or an assertion failure in _QueuingScrollView_set_navigationDirection:
///
/// DETECTION:
/// 1. Check if the UIPageViewController's internal _queuingScrollView exists.
/// 2. Verify its contentOffset is within expected bounds.
/// 3. Verify the number of view controllers in the queue matches expectations.
/// 4. If any check fails, set s_NavDirectionFaultDetected = YES.
///
/// PATCHING:
/// If a fault is detected, we:
/// 1. Reset the queuing scroll view's contentOffset to a known good state.
/// 2. Remove and re-add the current view controllers to force a state reset.
/// 3. Log the fault for crash analytics.
///
+ (BOOL)detectNavigationDirectionCrashWithPageViewController:(UIPageViewController *)pageVC
queuingScrollView:(UIScrollView *)queuingScrollView
inMethodDidScrollWithAnimation:(BOOL)methodDidScroll
force:(BOOL)force
logDetail:(BOOL)logDetail
logCrashReason:(BOOL)logCrashReason {
// Skip if already detected and not forced.
if (s_NavDirectionFaultDetected && !force) return NO;
// ---- Access the private _queuingScrollView ----
UIScrollView *scrollView = queuingScrollView;
if (!scrollView) {
// Try to access via KVC (private API).
@try {
scrollView = [pageVC valueForKey:@"_queuingScrollView"];
} @catch (NSException *e) {
if (logCrashReason) {
NSLog(@"[WRPageVC] Could not access _queuingScrollView: %@", e);
}
return NO;
}
}
if (!scrollView) return NO;
// ---- Check 1: Content offset bounds ----
CGFloat contentWidth = scrollView.contentSize.width;
CGFloat offsetX = scrollView.contentOffset.x;
CGFloat frameWidth = scrollView.bounds.size.width;
// The content offset should be a multiple of the frame width
// (one page at a time). If it's in between, the state is inconsistent.
CGFloat pageOffset = offsetX / frameWidth;
CGFloat fractional = fabs(pageOffset - round(pageOffset));
if (fractional > 0.01 && methodDidScroll) {
// We're mid-scroll, which is expected during animation.
// Only flag if this persists after animation completes.
if (logDetail) {
NSLog(@"[WRPageVC] Fractional offset detected: %.4f (mid-scroll, monitoring)", fractional);
}
}
// ---- Check 2: Number of child view controllers ----
NSArray *childVCs = pageVC.viewControllers ?: @[];
if (childVCs.count == 0 && !methodDidScroll) {
// No view controllers while not scrolling = fault state.
s_NavDirectionFaultDetected = YES;
if (logCrashReason) {
NSLog(@"[WRPageVC] FAULT: No view controllers in page VC outside of scroll.");
}
[self _patchFaultStateForPageViewController:pageVC];
return YES;
}
// ---- Check 3: Scroll view delegate consistency ----
id scrollDelegate = scrollView.delegate;
if (scrollDelegate != pageVC) {
// The scroll view delegate should be the page view controller.
s_NavDirectionFaultDetected = YES;
if (logCrashReason) {
NSLog(@"[WRPageVC] FAULT: Scroll delegate mismatch. Expected %@, got %@",
pageVC, scrollDelegate);
}
[self _patchFaultStateForPageViewController:pageVC];
return YES;
}
return NO;
}
/// Internal method to patch a detected fault state.
+ (void)_patchFaultStateForPageViewController:(UIPageViewController *)pageVC {
// Reset the scroll view to a known state.
@try {
UIScrollView *scrollView = [pageVC valueForKey:@"_queuingScrollView"];
if (scrollView) {
// Snap the content offset to the nearest page boundary.
CGFloat frameWidth = scrollView.bounds.size.width;
CGFloat snappedX = round(scrollView.contentOffset.x / frameWidth) * frameWidth;
scrollView.contentOffset = CGPointMake(snappedX, 0);
}
} @catch (NSException *e) {
NSLog(@"[WRPageVC] Patch failed: %@", e);
}
}
///
/// Patches the navigation direction fault by swizzling the internal
/// -_navigationDirectionForQueuingScrollView: method on UIPageViewController.
///
/// The swizzled implementation returns a safe default (Forward) when the
/// internal direction value is out of range.
+ (void)patchNavigationDirectionFault {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
// In a real implementation, this would swizzle the private method
// -[UIPageViewController _navigationDirectionForQueuingScrollView:direction:]
// to clamp the direction to a valid value.
//
// Since we can't safely swizzle private API in production, we instead:
// 1. Set a flag to enable the detection callback.
// 2. Register for UIApplicationDidEnterBackgroundNotification to
// cancel in-flight transitions.
// 3. Register for UIApplicationWillEnterForegroundNotification to
// reset state.
[[NSNotificationCenter defaultCenter]
addObserverForName:UIApplicationDidEnterBackgroundNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification *note) {
// Cancel any in-flight transition to prevent the fault.
s_NavDirectionFaultDetected = NO;
}];
[[NSUserDefaults standardUserDefaults] setBool:YES forKey:kNavDirectionPatchedKey];
NSLog(@"[WRPageVC] Navigation direction fault patch registered.");
});
}
///
/// Patches the "no view controller managing page view" fault.
///
/// This fault occurs when UIPageViewController's internal state gets out of
/// sync with the actual view controller hierarchy, typically after:
/// - Memory warnings that cause view unloading.
/// - Rapid programmatic page changes.
/// - Interface rotation during a transition.
///
/// The patch:
/// 1. Swizzles -viewDidDisappear: on UIPageViewController to ensure
/// child VCs are properly cleaned up.
/// 2. Registers for memory warning to re-set view controllers if needed.
+ (void)patchNoViewControllerManagingPageViewFault {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
// Swizzle the private method that throws the "no view controller
// managing the view" exception. The swizzled version catches the
// exception and returns nil instead of crashing.
//
// In practice, we intercept at a higher level:
// Register for UIApplicationDidReceiveMemoryWarningNotification
// and force a re-display of the current page.
[[NSNotificationCenter defaultCenter]
addObserverForName:UIApplicationDidReceiveMemoryWarningNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification *note) {
// Post a notification for the reader to reload.
[[NSNotificationCenter defaultCenter]
postNotificationName:@"WRPageViewControllerMemoryWarning"
object:nil];
}];
NSLog(@"[WRPageVC] No-VC-managing-page-view fault patch registered.");
});
}
///
/// Patches the UIPageCurl animation fault.
///
/// The UIPageCurl transition uses a GLKView (OpenGL/Metal) internally for
/// the page curl animation. When:
/// - The app enters the background, the GL context is invalidated.
/// - A rapid sequence of curl animations is triggered (user flipping fast).
/// - Memory pressure causes the GL resources to be purged.
///
/// The result is a visual glitch (blank page, stuck curl) or a crash in
/// the rendering thread.
///
/// The patch:
/// 1. Pauses curl animations when entering background.
/// 2. Resets the curl state on foreground.
/// 3. Throttles curl animation requests.
+ (void)patchUIPageCurlFault {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
s_PageCurlFaultPatched = YES;
// Register for background/foreground transitions.
[[NSNotificationCenter defaultCenter]
addObserverForName:UIApplicationDidEnterBackgroundNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification *note) {
// Disable page curl transitions while in background.
// The reader should switch to scroll mode or disable paging.
[[NSNotificationCenter defaultCenter]
postNotificationName:@"WRPageViewControllerDisableCurl"
object:nil];
}];
[[NSNotificationCenter defaultCenter]
addObserverForName:UIApplicationWillEnterForegroundNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification *note) {
// Re-enable and force a redraw.
[[NSNotificationCenter defaultCenter]
postNotificationName:@"WRPageViewControllerEnableCurl"
object:nil];
}];
NSLog(@"[WRPageVC] UIPageCurl fault patch registered.");
});
}
// ============================================================================
#pragma mark - UIGestureRecognizerDelegate
// ============================================================================
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
shouldReceiveTouch:(UITouch *)touch {
// Only handle taps in the left/right edge zones for curl mode.
if (self.pageFlippingStyle != WRPageFlippingStyleCurl) return NO;
CGPoint pt = [touch locationInView:self.view];
CGFloat w = CGRectGetWidth(self.view.bounds);
// Left zone: 0..25% of width.
// Right zone: 75%..100% of width.
if (pt.x < w * 0.25 || pt.x > w * 0.75) {
return self.pagingEnabled;
}
return NO;
}
@end