首先,来大致看一下 微信/QQ 小程序的功能。
Android端:
iOS端:
我们需要仿照 微信/QQ 的功能来实现自己的小程序功能,做成一个通用的 Cordova 插件。
Android端的资料比较多,创建快捷方式的时候需要注意
- API26(Android O)以下的版本,需要通过广播的方式创建快捷方式,在 API26(Android O)及以上的版本,可以直接通过API创建(ShortcutManager之类)。
- 添加到桌面失败没有报错,一般是权限问题,一定要在添加后给用户提示。
iOS端则比较复杂,网上资料比较少,所以就有了这篇文档,其实也主要是借鉴其他大佬的成果,加以汇总而已。
打开小程序比较简单,只需要新建一个 UIViewController 即可,需要注意的是,为了保持每次打开的状态,每个小程序对应一个 UIViewController,放到一个字典中,key为小程序的 id,value为对应的 UIViewController.
插件入口部分代码如下:
- (void)openMini:(CDVInvokedUrlCommand*)command
{
// 检查参数之类的代码
...
// 小程序信息保存到 NSUserDefaults
[self checkUserDefault:infoDic];
// 开启小程序
// 每个启动的小程序一个VC实例
if (taskDic == nil) {
taskDic = [NSMutableDictionary dictionary];
}
MiniTaskViewController *mtVC;
if ([[taskDic allKeys] containsObject:[infoDic objectForKey:@"id"]]) {
mtVC = [taskDic valueForKey:[infoDic objectForKey:@"id"]];
} else {
mtVC = [[MiniTaskViewController alloc] init];
mtVC.mini_id = [infoDic objectForKey:@"id"];
mtVC.mini_title = [infoDic objectForKey:@"name"];
mtVC.content_url = [infoDic objectForKey:@"url"];
mtVC.icon_url = [infoDic objectForKey:@"iconUrl"];
mtVC.modalPresentationStyle = 0;
[taskDic setObject:mtVC forKey:[infoDic objectForKey:@"id"]];
}
// 打开 小程序VC
[self.viewController presentViewController:mtVC animated:YES completion:nil];
}
// 保存到 NSUserDefault
- (void) checkUserDefault:(NSDictionary*) miniDic {
NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
NSString *infoKey = [NSString stringWithFormat:@"mini_task_%@", [miniDic valueForKey:@"id"]];
[userDefaults setObject:miniDic forKey:infoKey];
[userDefaults synchronize];
}
由于WKWebView 加载在线网址较慢,会有一段时间的白屏,所以添加一个加载中的视图提高用户体验,后续还是要用其他的方式进行优化。
代码如下:
// MiniTaskViewController.m
// loadingView
self.loadingView = [[UIView alloc] init];
[self.loadingView setBackgroundColor:[UIColor whiteColor]];
[self.view addSubview:self.loadingView];
[self.loadingView mas_makeConstraints:^(MASConstraintMaker *make) {
make.width.height.mas_equalTo(self.view);
}];
UILabel *label = [[UILabel alloc] init];
label.text = @"页面加载中,请稍候...";
[self.loadingView addSubview:label];
[label mas_makeConstraints:^(MASConstraintMaker *make) {
make.centerX.centerY.mas_equalTo(self.loadingView);
}];
UIImageView *imageView = [[UIImageView alloc] initWithImage:self.icon];
[self.loadingView addSubview:imageView];
[imageView mas_makeConstraints:^(MASConstraintMaker *make) {
make.centerX.mas_equalTo(self.loadingView);
make.bottom.mas_equalTo(label.mas_top).mas_offset(-10);
make.width.height.mas_equalTo(100);
}];
//进度条初始化
self.progressView = [[UIProgressView alloc] init];
self.progressView.backgroundColor = [UIColor blueColor];
self.progressView.layer.cornerRadius = 2;
//设置进度条的高度,下面这句代码表示进度条的宽度变为原来的1倍,高度变为原来的1.5倍.
// self.progressView.transform = CGAffineTransformMakeScale(1.0f, 1.5f);
[self.loadingView addSubview:self.progressView];
[self.progressView mas_makeConstraints:^(MASConstraintMaker *make) {
make.centerX.mas_equalTo(self.loadingView);
make.top.mas_equalTo(label.mas_bottom).mas_offset(10);
make.width.mas_equalTo(imageView).multipliedBy(2);
make.height.mas_equalTo(2);
}];
// WKWebView 初始化的时候添加监听
self.wkWebview addObserver:self forKeyPath:@"estimatedProgress" options:NSKeyValueObservingOptionNew context:nil];
// 必须实现此方法
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
if ([keyPath isEqualToString:@"estimatedProgress"]) {
self.progressView.progress = self.wkWebview.estimatedProgress;
if (self.progressView.progress == 1) {
/*
*添加一个简单的动画,将progressView的Height变为1.4倍,在开始加载网页的代理中会恢复为1.5倍
*动画时长0.25s,延时0.3s后开始动画
*动画结束后将progressView隐藏
*/
// 这里报错 Implicit declaration of function ‘typeof‘ is invalid in C99,可以修改 Build Settings -> C Language Dialect 为 GNU99 解决
// __weak typeof (self)weakSelf = self;
// [UIView animateWithDuration:0.25f delay:0.3f options:UIViewAnimationOptionCurveEaseOut animations:^{
// weakSelf.progressView.transform = CGAffineTransformMakeScale(1.0f, 1.4f);
// } completion:^(BOOL finished) {
// weakSelf.progressView.hidden = YES;
//
// }];
[UIView animateWithDuration:0.25f delay:0.3f options:UIViewAnimationOptionCurveEaseOut animations:^{
self.progressView.transform = CGAffineTransformMakeScale(1.0f, 1.4f);
} completion:^(BOOL finished) {
self.loadingView.hidden = YES;
}];
}
}else{
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
// 页面销毁的时候移除监听
- (void)dealloc {
[self.wkWebview removeObserver:self forKeyPath:@"estimatedProgress"];
}
#pragma mark - WKNavigationDelegate
- (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(WKNavigation *)navigation {
NSLog(@"小程序页面开始加载");
//开始加载网页时展示出progressView
self.loadingView.hidden = NO;
//开始加载网页的时候将progressView的Height恢复为1.5倍
self.progressView.transform = CGAffineTransformMakeScale(1.0f, 1.5f);
//防止progressView被网页挡住,这里由于我们最上层是操作按钮,所以注释掉
// [self.view bringSubviewToFront:self.loadingView];
}
iOS 的添加到桌面功能比较复杂,需要调用 Safari 浏览器,使用 Safari 的 共享 -> 添加到桌面,添加完成后,点击图标 打开对应的APP,或者APP内对应的页面。
大致步骤如下:
添加插件时,会让用户设置 IOS_URL_SCHEME 参数,插件会基于这条 URL Scheme 开发,如果要体验手动添加的话,直接打开对应的TARGET -> info -> URL Types,添加需要的 URL Scheme即可。
这里使用的是本地服务器的方式,使用的是第三方库 GCDWebServer,需要在 Podfile 里面添加 pod ‘GCDWebServer’
shortcut.html 内容如下,带 -PLACEHOLDER 后缀的是需要在代码中进行替换的。
DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="#ffffff">
<meta name="apple-mobile-web-app-title" content="TITLE-PLACEHOLDER">
<link rel="apple-touch-icon-precomposed" href="data:image/jpeg;base64,ICON-PLACEHOLDER"/>
<title>TITLE-PLACEHOLDERtitle>
head>
<script>document.documentElement.style.fontSize = 100 \* document.documentElement.clientWidth / 375 + "px"script>
<style>
\* { margin: 0; padding: 0 }
body, html { height: 100%; width: 100%; overflow: hidden; background: #f3f2f2; text-align: center }
.main { color: #333; text-align: center }
.subject { margin-top: 1rem; font-size: .2rem }
.guide { width: 100%; position: absolute; left: 0; bottom: .3rem }
.guide .content { position: relative; z-index: 20; width: 3.5rem; padding-top: .16rem; padding-bottom: .06rem; margin: 0 auto; border-radius: .04rem; box-shadow: 0 6px 15px rgba(0, 0, 0, .13); background: #fff; font-size: .14rem }
.guide .tips { position: relative; z-index: 20 }
.guide .icon { width: .2rem; height: .24rem; margin: 0 .035rem .02rem; vertical-align: bottom }
.guide .toolbar { width: 100%; height: auto; margin-top: -.12rem; position: relative; z-index: 10 }
.guide .arrow { width: .27rem; height: auto; position: absolute; left: 50%; bottom: -.26rem; margin-left: -.135rem; z-index: 10 }
style>
<body>
<a id="redirect" href="URL-PLACEHOLDER">a>
<div id="container">
<div class="main">
<div class="subject">添加快捷功能到桌面div>
div>
<div class="guide">
<div class="content">
<p class="tips">
点击下方工具栏上的<img class="icon" src="https://dariel-1256714552.cos.ap-shanghai.myqcloud.com/XEbFrgamEdvSxVFOBeuZ.png">
p>
<p class="tips">
并选择<img class="icon" src="https://dariel-1256714552.cos.ap-shanghai.myqcloud.com/IkKEhyTLQpYtqXMZBYtQ.png">“<strong>添加到主屏幕strong>”
p>
<img class="toolbar" src="https://dariel-1256714552.cos.ap-shanghai.myqcloud.com/oFNuXVhPJYvBDJPXJTmt.jpg">
<img class="arrow" src="https://dariel-1256714552.cos.ap-shanghai.myqcloud.com/FlBEnTRnlhMyLyVhlfZT.png">
div>
div>
div>
body>
html>
<script type="text/javascript">
if (window.navigator.standalone) {
var element = document.getElementById('container');
element.style.display = "none";
var element = document.getElementById('redirect');
var event = document.createEvent('MouseEvents');
event.initEvent('click', true, true, document.defaultView, 1, 0, 0, 0, 0, false, false, false, false, 0, null);
document.body.style.backgroundColor = '#FFFFFF';
setTimeout(function() { element.dispatchEvent(event); }, 25);
} else {
var element = document.getElementById('container');
element.style.display = "inline";
}
script>
使用GCDWebServer本地服务器加载引导页面,在Safari展示,引导用户将页面添加到桌面。
- (void) addShortCutToHomeScreen {
NSURL *schemeUrl = [NSURL URLWithString:[NSString stringWithFormat:@"%@://mini/%@", [self getUrlScheme], self.mini_id]];
NSString *iconBase64Str = [UIImageJPEGRepresentation(self.icon, 0.5) base64EncodedStringWithOptions:NSDataBase64Encoding64CharacterLineLength];
NSString *htmlContent = [self htmlWithTitle:self.mini_title urlToRedirect:schemeUrl.absoluteString iconStr:iconBase64Str];
htmlContent = [[htmlContent dataUsingEncoding:NSUTF8StringEncoding] base64EncodedStringWithOptions:0];
htmlContent = [NSString stringWithFormat:@"data:text/html;base64,%@", htmlContent];
self.webServer = [[GCDWebServer alloc] init];
[self.webServer addDefaultHandlerForMethod:@"GET" requestClass:[GCDWebServerRequest class] processBlock:^GCDWebServerResponse * _Nullable(__kindof GCDWebServerRequest * _Nonnull request) {
return [GCDWebServerDataResponse responseWithRedirect:[NSURL URLWithString:htmlContent] permanent:YES];
}];
[self.webServer startWithPort:7799 bonjourName:nil];
[[UIApplication sharedApplication] openURL:self.webServer.serverURL options:@{} completionHandler:^(BOOL success) {
// 稍微睡眠一秒再停止 webserver
[NSThread sleepForTimeInterval:1];
[self.webServer stop];
}];
}
- (NSString*) getUrlScheme {
NSString *bundleName = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleName"];
NSArray *urlSchemes = [[[[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleURLTypes"] firstObject] objectForKey:@"CFBundleURLSchemes"];
return [urlSchemes firstObject];
}
/**
获取 html 内容,并替换
*/
- (NSString*) htmlWithTitle:(NSString*) title urlToRedirect:(NSString*)schemeUrl iconStr:(NSString*) iconBase64Str {
NSString *htmlContent = [NSString stringWithContentsOfFile:[[NSBundle mainBundle] pathForResource:@"/www/shortcut.html" ofType:nil] encoding:NSUTF8StringEncoding error:nil];
htmlContent = [htmlContent stringByReplacingOccurrencesOfString:@"TITLE-PLACEHOLDER" withString:title];
htmlContent = [htmlContent stringByReplacingOccurrencesOfString:@"URL-PLACEHOLDER" withString:schemeUrl];
htmlContent = [htmlContent stringByReplacingOccurrencesOfString:@"ICON-PLACEHOLDER" withString:iconBase64Str];
return htmlContent;
}
在 AppDelegate.m 引用插件自己的方法。
//
// AppDelegate.m
// test1209
//
// Created by ___FULLUSERNAME___ on ___DATE___.
// Copyright ___ORGANIZATIONNAME___ ___YEAR___. All rights reserved.
//
#import "AppDelegate.h"
#import "MainViewController.h"
#import "AppDelegate_MiniTask.h"
@implementation AppDelegate
- (BOOL)application:(UIApplication*)application didFinishLaunchingWithOptions:(NSDictionary*)launchOptions
{
self.viewController = [[MainViewController alloc] init];
return [super application:application didFinishLaunchingWithOptions:launchOptions];
}
// 后台运行
- (void)applicationDidEnterBackground:(UIApplication *)application {
[[UIApplication sharedApplication] beginBackgroundTaskWithExpirationHandler:nil];
}
// 接受到 URL Scheme
- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary *)options {
[[AppDelegate_MiniTask sharedInstance] minitask_application:app openURL:url options:options];
return YES;
}
// APP 停止
- (void)applicationWillTerminate:(UIApplication *)application {
[[AppDelegate_MiniTask sharedInstance] minitask_applicationWillTerminate:application];
NSLog(@"APP 将停止");
}
@end
AppDelegate_MiniTask内容如下:
//
// AppDelegate_MiniTask.m
// testMini1202
//
// Created by ecidi on 2021/12/9.
//
#import "AppDelegate_MiniTask.h"
#import "MainViewController.h"
#import "MiniTaskViewController.h"
@implementation AppDelegate_MiniTask
static AppDelegate_MiniTask* _instance = nil;
+ (instancetype) sharedInstance {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
_instance = [[self alloc] init];
});
return _instance;
}
- (void)minitask_applicationWillTerminate:(UIApplication *)application {
NSLog(@"MiniTask... ..APP 将停止");
}
- (BOOL)minitask_application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary *)options {
if ([url.absoluteString hasPrefix: [NSString stringWithFormat:@"%@://", [self getUrlScheme]]]) {
if ([url.host isEqualToString:@"mini"]) {
NSString* mini_id = [url.path substringFromIndex:1];
NSDictionary *infoDic = [self getMiniInfoDicWithId:mini_id];
if (infoDic == nil || [infoDic allKeys].count < 1) {
return YES;
}
NSLog(@"%@ call...", [infoDic valueForKey:@"name"]);
UIViewController *rootVC = [UIApplication sharedApplication].keyWindow.rootViewController;
UIViewController* currentVC = [self getCurrentVCFrom:rootVC];
if ([currentVC isKindOfClass:[MiniTaskViewController class]]) {
// 如果当前VC就是 schemeurl 对应的,直接返回
MiniTaskViewController *vc = (MiniTaskViewController*)currentVC;
if ([vc.content_url isEqualToString:[infoDic valueForKey:@"url"]]) {
return YES;
}
} else if ([currentVC isKindOfClass:[MainViewController class]]) {
NSLog(@"当前页面为MainViewController");
}
// 先判断 MainViewController 里的 webview 是否已登录,暂时写死
bool loginFlag = YES;
if (loginFlag) {
// 如果当前VC不是 schemeurl 对应的,打开
MiniTaskViewController *mtVC = [[MiniTaskViewController alloc] init];
mtVC.mini_id = mini_id;
mtVC.mini_title = [infoDic objectForKey:@"name"];
mtVC.content_url = [infoDic objectForKey:@"url"];
mtVC.icon_url = [infoDic objectForKey:@"iconUrl"];
[currentVC presentViewController:mtVC animated:YES completion:nil];
}
}
}
return YES;
}
-(UIViewController*) getCurrentVCFrom:(UIViewController*) rootVC {
UIViewController *currentVC;
if ([rootVC presentedViewController]) {
// 视图是被 presented 出来的
rootVC = [rootVC presentedViewController];
}
if ([rootVC isKindOfClass:[UITabBarController class]]) {
currentVC = [self getCurrentVCFrom:[(UITabBarController*)rootVC selectedViewController]];
} else if ([rootVC isKindOfClass:[UINavigationController class]]) {
currentVC = [self getCurrentVCFrom:[(UINavigationController*)rootVC visibleViewController]];
} else {
currentVC = rootVC;
}
return currentVC;
}
-(NSDictionary*) getMiniInfoDicWithId:(NSString*) mini_id {
NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
NSString *infoKey = [NSString stringWithFormat:@"mini_task_%@", mini_id];
NSDictionary *infoDic = [userDefaults objectForKey:infoKey];
return infoDic;
}
- (NSString*) getUrlScheme {
NSString *bundleName = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleName"];
NSArray *urlSchemes = [[[[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleURLTypes"] firstObject] objectForKey:@"CFBundleURLSchemes"];
return [urlSchemes firstObject];
}
@end
问题: WKWebView 未占用状态栏
解决: 在WKWebView初始化的时候,添加如下代码
// 全屏(占用状态栏)
if (@available(iOS 11.0, *)) {
self.wkWebview.scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
} else {
self.edgesForExtendedLayout = UIRectEdgeNone;
}
问题: GCDWebServer 在应用进入后台后,会自动停止,导致Safari打不开本地服务器地址。
解决: 参考 https://www.jianshu.com/p/2bab0ef93f7d ,做以下三处修改即可。
- 将GCDWebServer.m中的GCDWebServerOption_AutomaticallySuspendInBackground设置为NO
- 打开Background Modes
- 在AppDelegate.m 的 applicationDidEnterBackground 方法添加 [[UIApplication sharedApplication] beginBackgroundTaskWithExpirationHandler:nil];
问题: 在修改图标后,添加到桌面还是原来的图标,卸载APP重装也不行
解决: 在设置中,删除Safari的缓存即可。
问题: typeof 报错
解决: C99不支持这种写法,换用 GNU99即可,文中暂时直接用self。
**问题:**小程序插件在 AppDelegate.m 塞了太多东西,需要单独抽出来
解决: 使用 组合设计模式 ,将插件相关方法抽成一个类,在AppDelegate.m对应的生命周期方法调用。
问题: 在主应用未登录的时候,即便是对应的URL Scheme也要进入主应用的页面
解决: 目前的想法是以下三种情况
- 调用插件接口进入小程序时,将已登录状态保存到NSUserDefaults中
- 主APP退出登录,调用插件接口将登录状态改为 未登录
- 应用停止,在AppDelegate 的 applicationWillTerminate 方法中将状态改为 未登录