OC 代码 webview 执行 JS,如何有效的消除回调地狱

OC 代码 webview 执行 JS,如何有效的消除回调地狱

在项目中,偶发的报一个 JS 错误 A lavaScript exception occurred ,找到这段的具体实现

const scrDom = document.createElement("script");
scrDom.src = basic.min.js;
scrDom.addEventListener("load", () => {
  if (!haveMacJS) {
    window.webkit.messageHandlers.setPlanSucceed.postMessage("");
  }
});
const macScrDom = document.createElement("script");
macScrDom.src = mac.min.js;
macScrDom.addEventListener("load", () => {
  window.webkit.messageHandlers.setPlanSucceed.postMessage("");
});

发现逻辑是当需要在 webView 中 加载两个精算文件,并且等到这两个精算文件都加载完毕后,再通知 native 端,把参数传回到 webView,执行计算,发现这段代码并不是按顺序执行完,然后才通知 native 端的.

出现问题的原因是,需要在 JS 端和 Native 端来回传参,无形中增加了问题出现的概率,为了遵循相同逻辑,同一地方处理的原则,就在 native 端加载这两个 JS 文件

NSString *basicString = [NSString stringWithContentsOfFile: basicJsPath encoding:NSUTF8StringEncoding error: nil];
// 加载第一个js文件
[webView evaluateJavaScript: basicString completionHandler:^(id _Nullable res, NSError * _Nullable basicError) {
	  //如果需要加载第二个文件
		if ([RFileTool existFileAtPath: macroJsPath])
	  {
				//第二个精算文件存在的话,加载它
        NSString *macroString = [NSString stringWithContentsOfFile: macroJsPath encoding:NSUTF8StringEncoding error:nil];
        [weakWebView evaluateJavaScript: macroString completionHandler:^(id _Nullable response, NSError * _Nullable macroError) {
            // 第二个文件加载成功的话,通知webView,执行初始化操作
            NSString *javaScriptString = [NSString stringWithFormat:@"setPlan('%@',%d)", weakSelf.lastPlanId, YES];
            [weakWebView evaluateJavaScript:javaScriptString completionHandler:^(id _Nullable response, NSError * _Nullable error) {
                //初始化成功,调用native的计算方法.
								[weakSelf loadCalculate];
            }];
        }];
    }
		else
		{
				// 不需要加载第二个精算文件的话,通知webView,执行初始化操作
        NSString *javaScriptString = [NSString stringWithFormat:@"setPlan('%@',%d)", weakSelf.lastPlanId, NO];
        [weakWebView evaluateJavaScript:javaScriptString completionHandler:^(id _Nullable response, NSError * _Nullable error) {
            //初始化成功,调用native的计算方法.
						[weakSelf loadCalculate];
        }];
		}
}];

以上一个的方法中,套了三层回调,这还是省略了处理错误的情况下,如果再加上错误处理,这里的代码将会变的非常的多以及丑陋.

如果能像其他语言中,Promise 链式调用方式,代码就会变的清晰了,可惜 OC 不支持 Promise,只能自己实现了.

先看下最终效果,然后讲解具体的实现逻辑,虽然代码量看上去没少多少,但是调用逻辑清晰多了,也没有那么多重复代码了.当然这里每一个 promise 对象都有一个 catch,这里是为了统计具体是哪一个 js 加载出错了,如果不需要这样统计,可以修改代码,统一 catch

//promise入口,使用类方法firstlyWithJSEvaluator,创建第一个promise
[[[[[[[RJSEvalPromise firstlyWithJSEvaluator: webView js: ^NSString * (void) {
    //返回需要加载的js文件
    NSString *basicString = [NSString stringWithContentsOfFile: basicJsPath encoding:NSUTF8StringEncoding error: nil];
    return basicString;
}] catch:^(NSError * _Nonnull basicError) {
		//处理加载js出错的情况
    NSLogDebug(@"❌---load basic js file:%@ faile--error:%@---❌", self.lastPlanId, basicError);
}] then: ^NSString * (void) {
		//第一个js执行完毕后,这里返回需要加载的第二个js文件
    if (isHaveMacro)
    {
        NSString *macroString = [NSString stringWithContentsOfFile: macroJsPath encoding:NSUTF8StringEncoding error: nil];
        return macroString;
    }
    return nil;
}] catch:^(NSError *macroError) {
		//处理加载js出错的情况
    NSLogDebug(@"❌---load macro js file:%@ faile--error:%@---❌", self.lastPlanId, macroError);
}] then: ^NSString * (void) {
		//通知webView,执行初始化操作
    NSString *javaScriptString = [NSString stringWithFormat:@"setPlan('%@',%d)", self.lastPlanId, isHaveMacro];
    return javaScriptString;
}] catch:^(NSError *setPlanError) {
    NSLogDebug(@"❌---set plan:%@ faile--error:%@---❌", self.lastPlanId, setPlanError);
}] finaly: ^NSString * (void) {
		//初始化成功,调用native的计算方法.
		[self loadCalculate];
}];

RJSEvalPromise 的实现逻辑

firstlyWithJSEvaluator 这个方法需要传两个参数,第一个是 JS 执行环境,也就是 webView, 第二个是一个回调函数,类型定义为 typedef NSString *_Nullable(^SQSPromiseFirstCallback)(void); ,回调函数调用后,返回的就是需要执行的 JS 代码,这里直接返回了 promise,却没对它强引用,会不会导致在 JS 执行前自动释放呢,答案是不会,因为在 aciton 实现中, webView 的回调函数会捕获这个 promise 对象

//RJSEvalPromise.m
+ (instancetype)firstlyWithJSEvaluator:(WKWebView *)jsEvaluator js:(SQSPromiseFirstCallback)js {
    RJSEvalPromise* promise = [RJSEvalPromise makePromiseWithJSEvaluator: jsEvaluator js: js];
    promise.aciton();
    promise.aciton = nil;
    return promise;
}

makePromiseWithJSEvaluator 就是真正创建 promise 对象的方法,这里定义 promise 的具体执行逻辑,这里 promise 的 action 中直接使用了 promise,却不会循环引用的原因是上边在调用 action 后,直接把 action 手动置为 nil,消除了循环引用,但是编译器会弹出警告,所以加上 diagnostic ignored 让编译器忽略掉这个警告

+ (instancetype)makePromiseWithJSEvaluator:(WKWebView *)jsEvaluator js:(SQSPromiseFirstCallback)js{
    RJSEvalPromise * promise = [[RJSEvalPromise alloc] init];
    promise.jsEvaluator = jsEvaluator;
    promise.state = RJSEvalPromiseStatePending;
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-retain-cycles"
    promise.aciton = ^{
        NSString *string = js();
        // if no need evaluateJavaScript, then Fulfilled.
        if (!string)
        {
            promise.state = RJSEvalPromiseStateFulfilled;
            [promise resume];
            return;
        }
        [jsEvaluator evaluateJavaScript: string completionHandler:^(id _Nullable result, NSError * _Nullable error) {
            promise.result = result;
            promise.error = error;
            if (error) {
                promise.state = RJSEvalPromiseStateRejected;
            }else{
                promise.state = RJSEvalPromiseStateFulfilled;
            }
            [promise resume];
        }];
    };
#pragma clang diagnostic pop
    return promise;
}

webView 调用evaluateJavaScript 方法,执行 JS 后,拿到执行的具体结果,给当前的 promise 赋值对应的状态.然后调用 resume 方法,看是否当前的 promise 链,已经调用完毕了

resume 的实现就比较简单了,如果状态是 Pending 的话,直接返回,是 Rejected ,那么出现错误了,调 catch 回调函数,然后看是否有下一个 promise,有的话就执行它的 action 方法,直到 promise 链调用完毕

- (void)resume {
    if (self.state == RJSEvalPromiseStatePending)
    {
        return;
    }
    if (self.state == RJSEvalPromiseStateRejected)
    {
        if (self.catchCallback) {
            self.catchCallback(self.error);
        }
    }
    if (self.nextFuture.aciton) {
        self.nextFuture.aciton();
    }
    self.nextFuture.aciton = nil;
    if (self.finalCallback) {
        self.finalCallback(self.result);
    }else if (self.emptyFinalCallback) {
        self.emptyFinalCallback();
    }
}

当然这里,也可以修改为,如果发生错误的话,就不再执行下一个 promise 了,直接循环查找最后一个 catch 回调.

if (self.state == RJSEvalPromiseStateRejected)
{
    while (self.nextFuture && self.nextFuture.catchCallback) {
        self.catchCallback = self.nextFuture.catchCallback;
    }
    if (self.catchCallback) {
        self.catchCallback(self.error);
    }
}

哦,对了,还有 then 方法,它的实现和 firstlyWithJSEvaluator基本相同,调用 makePromiseWithJSEvaluator 然后保存创建的这个 promise 对象.

- (instancetype)then:(SQSPromiseFirstCallback)callback {
    RJSEvalPromise* promise = [RJSEvalPromise makePromiseWithJSEvaluator: self.jsEvaluator js: callback];
    self.nextFuture = promise;
    [self resume];
    return promise;
}

还有 catchfinally 方法都是仅仅保存了回调函数,等待执行 JS 出结果后,在 resume 方法中调用

- (instancetype)catch:(void (^)(NSError * _Nonnull))callback {
    self.catchCallback = callback;
    [self resume];
    return self;
}

- (void)finally:(void (^)(void))callback {
    self.emptyFinalCallback = callback;
    [self resume];
}

当然这样做,有一大堆的[] , 如何通过.(点)调用方法呢? iOS 中使用点调用属性的本质都是调用属性的 getter 方法来获取属性值. 那么连续使用的.调用,需要满足什么条件呢?

1: 必须是对象方法;

2: 必须有返回值,返回值的类型是当前对象类型

3: 一定是没有参数的

看到第三点的要求,不能传参数,那怎么把要执行的 JS 传递过去呢? 那么可以做个变更,返回值不再是一个 promise 对像,而返回一个 block,这个 block,调用后,再返回个 promise 对象,就满足了要求

typedef RJSEvalPromise *_Nullable(^SQSPromiseDotThen)(SQSPromiseFirstCallback);

- (SQSPromiseDotThen)then {
    return ^RJSEvalPromise * _Nullable(SQSPromiseFirstCallback _Nonnull callback) {
        RJSEvalPromise* promise = [RJSEvalPromise makePromiseWithJSEvaluator: self.jsEvaluator js: callback jsResult: nil result: nil];
        self.nextFuture = promise;
        [self resume];
        return promise;
    };
}

这样就达到了,使用.语法的链式调用了

[[RJSEvalPromise firstlyWithJSEvaluator: webView js: ^NSString * (void) {
    NSString *basicString = [NSString stringWithContentsOfFile: basicJsPath encoding:NSUTF8StringEncoding error: nil];
    return basicString;
}].dotThen(^NSString * (void) {
    if (isHaveMacro)
    {
        NSString *macroString = [NSString stringWithContentsOfFile: macroJsPath encoding:NSUTF8StringEncoding error: nil];
        return macroString;
    }
    return nil;
}).dotThen(^NSString * (void) {
    NSString *javaScriptString = [NSString stringWithFormat:@"setPlan('%@',%d)", self.lastPlanId, isHaveMacro];
    return javaScriptString;
}) finally:^{

}];

至此,完成了回调地狱到 promise 链式调用的转换.

本博客文章采用 CC 4.0 协议,转载需注明出处和作者。