仿 微信/QQ 实现小程序功能 -IOS

仿 微信/QQ 实现小程序功能 -IOS

1. 需求

首先,来大致看一下 微信/QQ 小程序的功能。

Android端:

  • 点击图标进入小程序,会新开一个任务栈,每个小程序一个任务栈(有的机型所有小程序都在一个任务栈),不影响主APP,所以在多任务管理中,我们可以看到是有多个任务的。
  • 只要APP没有杀进程,每次打开都是原先的状态,说明没有重新初始化
  • 可以添加到桌面,点击之后会进入相应的小程序,如果未登录,则只进到登录页面
  • 小程序拥有自己的图标和名称
  • 添加到桌面 不能动态申请权限,只能提示用户前往应用设置界面手动添加 桌面快捷方式 权限。

iOS端:

  • 点击图标进入小程序,在APP内展示小程序页面
  • 只要APP没有被杀进程或者主动退出,每次打开都是原先的状态,说明没有重新初始化
  • 使用Safari浏览器的添加到桌面功能 添加到桌面,点击之后会进入相应的小程序,如果未登录,则只进到登录页面
  • 小程序拥有自己的图标和名称
  • 无需其他权限

我们需要仿照 微信/QQ 的功能来实现自己的小程序功能,做成一个通用的 Cordova 插件。

Android端的资料比较多,创建快捷方式的时候需要注意

  1. API26(Android O)以下的版本,需要通过广播的方式创建快捷方式,在 API26(Android O)及以上的版本,可以直接通过API创建(ShortcutManager之类)。
  2. 添加到桌面失败没有报错,一般是权限问题,一定要在添加后给用户提示。

iOS端则比较复杂,网上资料比较少,所以就有了这篇文档,其实也主要是借鉴其他大佬的成果,加以汇总而已。

2. 实现

2.1 打开小程序

打开小程序比较简单,只需要新建一个 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];
    
}

2.2 WKWebView加载中视图

由于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<NSString *,id> *)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];
}

2.3 添加到主屏幕

iOS 的添加到桌面功能比较复杂,需要调用 Safari 浏览器,使用 Safari 的 共享 -> 添加到桌面,添加完成后,点击图标 打开对应的APP,或者APP内对应的页面。

大致步骤如下:

  1. 设置 APP 的 URL Scheme
  2. 开发引导页面(在线,或者本地服务器)
  3. 重写 AppDelegate.m 的 openURL 方法,进入相应页面
2.3.1 URL Scheme

添加插件时,会让用户设置 IOS_URL_SCHEME 参数,插件会基于这条 URL Scheme 开发,如果要体验手动添加的话,直接打开对应的TARGET -> info -> URL Types,添加需要的 URL Scheme即可。

URL

2.3.2 引导页面

这里使用的是本地服务器的方式,使用的是第三方库 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-PLACEHOLDER</title>

 </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;
}
2.3.3 AppDelegate.m

在 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<UIApplicationOpenURLOptionsKey,id> *)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<UIApplicationOpenURLOptionsKey,id> *)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

3. 问题

3.1 WKWebView 未占满全屏

问题: WKWebView 未占用状态栏

解决: 在WKWebView初始化的时候,添加如下代码

// 全屏(占用状态栏)
if (@available(iOS 11.0, *)) {
    self.wkWebview.scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
} else {
    self.edgesForExtendedLayout = UIRectEdgeNone;
}

3.2 GCDWebServer 后台运行

问题: GCDWebServer 在应用进入后台后,会自动停止,导致Safari打不开本地服务器地址。

解决: 参考 https://www.jianshu.com/p/2bab0ef93f7d ,做以下三处修改即可。

  1. 将GCDWebServer.m中的GCDWebServerOption_AutomaticallySuspendInBackground设置为NO
  2. 打开Background Modes
  3. 在AppDelegate.m 的 applicationDidEnterBackground 方法添加 [[UIApplication sharedApplication] beginBackgroundTaskWithExpirationHandler:nil];

3.3 修改代码,引导页面内容未变

问题: 在修改图标后,添加到桌面还是原来的图标,卸载APP重装也不行

解决: 在设置中,删除Safari的缓存即可。

3.4 Implicit declaration of function ‘typeof‘ is invalid in C99

问题: typeof 报错

解决: C99不支持这种写法,换用 GNU99即可,文中暂时直接用self。

3.5 AppDelegate瘦身

**问题:**小程序插件在 AppDelegate.m 塞了太多东西,需要单独抽出来

解决: 使用 组合设计模式 ,将插件相关方法抽成一个类,在AppDelegate.m对应的生命周期方法调用。

3.6 怎么把登录状态存起来

问题: 在主应用未登录的时候,即便是对应的URL Scheme也要进入主应用的页面

解决: 目前的想法是以下三种情况

  1. 调用插件接口进入小程序时,将已登录状态保存到NSUserDefaults中
  2. 主APP退出登录,调用插件接口将登录状态改为 未登录
  3. 应用停止,在AppDelegate 的 applicationWillTerminate 方法中将状态改为 未登录

4. 参考

  1. iOS APP添加桌面快捷方式
  2. iOS WKWebView的使用
  3. iOS - 关于 KVO 的一些总结
  4. iOS本地服务器-GCDWebServer支持后台运行
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值