小组件开发最佳实践(iOS)

应用程序小组件是一个微型的应用程序视图,可以嵌入其他应用程序(例如主屏幕)中并接收定期更新。本文档介绍了开发iOS小组件。

创建证书

iOS中小组件(Widget)是一个独立的应用,可以看做是一个独立的App(宿主App的拓展程序),所以我们需要对Widget单独创建证书。

  1. 创建宿主App证书。

    该部分操作资料很丰富,此处不做介绍,可自行查找相关资料。

  2. 创建Widget证书。

    创建Widget证书的操作与创建宿主App证书的操作类似,但需注意以下几点。

    • Widget的Bundle Id是以宿主App为基础扩展的,例如宿主App为com.companyName.AppName,则Widget的格式应该为com.companyName.AppName.WidgetName配置证书示例

    • 创建证书的时候,需勾选App Group配置项。勾选App Groups

创建Widget

  1. 在xcode中,选择File > New > Target > Today Extension,创建Today。

    创建today

  2. 查看创建后的目录结构。

    目录结构

  3. 设置Widget工程的开发方式。

    工程默认为storyboard开发方式,如果想使用纯代码方式,则需要进行以下操作。

    TodayWidget > Info.plist > Extension中,删除NSExtensionMainStoryboard选项 ,增加NSExtensionPrincipalClass选项,value为类的名字IMSWidgetTestViewController,如下图所示。

    配置示例

  4. 设置Widget的展开与折叠效果,示例如下。

    - (void)viewWillAppear:(BOOL)animated {
        [super viewWillAppear:animated];
        if (/*折叠展开判断 */) {
            self.extensionContext.widgetLargestAvailableDisplayMode = NCWidgetDisplayModeExpanded;
        } else {
            self.extensionContext.widgetLargestAvailableDisplayMode = NCWidgetDisplayModeCompact;
        }
    }
    
    - (void)widgetActiveDisplayModeDidChange:(NCWidgetDisplayMode)activeDisplayMode withMaximumSize:(CGSize)maxSize {
        switch (activeDisplayMode) {
            case NCWidgetDisplayModeCompact: {
                self.preferredContentSize = maxSize;
                break;
            }
            case NCWidgetDisplayModeExpanded: {
                self.preferredContentSize = CGSizeMake(self.view.bounds.size.width, 210);
                break;
            }
            default:
                break;
        }
    }
    说明
    • 展开的高度可以自行设置,不超过系统最大值即可。

    • 系统不支持折叠高度的修改。

  5. 刷新数据,建议使用系统提供的方法,示例如下。

    - (void)widgetPerformUpdateWithCompletionHandler:(void (^)(NCUpdateResult))completionHandler {
        completionHandler(NCUpdateResultNewData);
    }
    说明

    此处刷新有可能执行失败,这是目前Apple存量的问题,可通过延迟来解决。详细请参见延时的原因临时解决方案

  6. 配置小组件与宿主App的跳转功能。

    Extension和宿主App是两个完全独立的进程,它们之间不能直接通信(即无法通过单击应用内部按钮跳转到指定页面)。为了实现Widget调起宿主App,这里通过openURL的方式来启动宿主App。

    1. 在宿主App里选择Targets > MCWidgetDemo > Info > Url Types,添加URL Schemes。

      下图为设置示例,设置URL SchemesTodayWidget

      配置URL Schemes

    2. 配置代码跳转地址(openURL)。完整地址为:”URL Schemes” + “://” + “宿主App Bundle Id”,如下图所示。

      配置跳转地址

设置Widget和宿主App交互通信

因为Widget的独立性,宿主App要与Widget之间相互通信,需要通过App Group来实现。

  1. 创建App Group。

    前往开发者网站注册一个App Group,填入名字和id,并根据界面提示操作,即可得到下图类似的App Group。App Group

  2. Target > Signing & Capabilities > App Group下,配置App Group。

    在宿主App和扩展程序(Widget)的App Group中,分别设置group名称。需确保宿主App和Widget的groupName相同,并且与在开发者网站注册的App Groups保持一致。配置示例

  3. 配置Widget和宿主App之间交互通信。

    使用NSUserDefaults或者NSFileManager方式都可以实现Widget和宿主App之间交互通信。此处介绍如何使用NSUserDefaults方式实现交互通信。

    • 存数据存数据

    • 取数据取数据

生活物联网平台SDK使用指导

下面主要介绍TodayExtension的开发过程,其余Widget开发请参照Apple官方文档自行完成。

  1. 引入SDK。

    1. 设置Profile。

      iOS推荐使用Cocoapods引入,分别对宿主App Target和Widget Target引入SDK。因为Widget是独立的应用,所以两个Target都需要各自引入编译所需的SDK,多个小组件,就配置多份Profile。配置示例如下。

      target “WidgetTargetName1” do
          pod 'IMSApiClient', '1.6.0'
          pod 'IMSAuthentication', '1.4.1'
      end
      
      target “WidgetTargetName2” do
          pod 'IMSApiClient', '1.6.0'
          pod 'IMSAuthentication', '1.4.1'
      end
    2. 查看小组件开发必备SDK列表。

      小组件开发必备SDK列表
      【1】通用请求SDK
          pod 'IMSApiClient', '1.6.0'
          pod 'IMSAuthentication', '1.4.1'
      【2】设备小组件相关SDK
          # 物
          pod 'IMSThingCapability', '1.7.5'
          # 长连接
          pod 'IMSMobileChannel', '1.6.7'
    3. 执行pod update,并编译工程。

      编译成功后,选择Widget的target,运行小组件工程。

      说明

      因为Widget的独立,安全图片也需要导入一份到Widget的Target下,否则会报错。

  2. 初始化宿主App配置。

    1. 初始化宿主App的IMSAuthentication,示例代码如下。

      // 设置需要更新的Credential至AppGroup中
      [[IMSCredentialManager sharedManager] addCredentialStoreWithAppGroupName:AppGroupName];
    2. 把ApiClient的信息,写入对应的AppGroup共享区域中。

      // 宿主App初始化IMSApiClient完成后,把ApiClient的信息,写入到对应的AppGroup共享区域中
      [[IMSConfiguration sharedInstance] storeConfigToAppGroup:AppGroupName];
  3. 配置TodayExtension,配置示例代码如下。

    + (void)initialize {
        // 初始化APIClient
            [IMSConfiguration initWithAppGroupName:AppGroupName];
            // 初始化身份认证
            [IMSCredentialManager initWithAppGroupName:AppGroupName];
        // 注册RequestClient的代理
        IMSIoTAuthentication *iotAuthDelegate = [[IMSIoTAuthentication alloc] initWithCredentialManager:IMSCredentialManager.sharedManager];
        [IMSRequestClient registerDelegate:iotAuthDelegate forAuthenticationType:IMSAuthenticationTypeIoT];
    }
    
    - (void)viewWillAppear:(BOOL)animated {
        [super viewWillAppear:animated];
    
        // 根据AppGroup共享区域存储的信息
        // 防止出现未打开宿主App、已经初始化Extension的APIClient
        [[IMSConfiguration sharedInstance] synchronizeConfigFromAppGroup];
        // 从UserDefaults更新Credential
        [[IMSCredentialManager sharedManager] synchronizeCredentialFromAppGroup];
    }
  4. 调用接口,示例代码如下。

     IMSIoTRequestBuilder *builder = [[IMSIoTRequestBuilder alloc] initWithPath:@"/uc/path/xxxx"
                                                                        apiVersion:@"1.0.0"
                                                                            params:@{}];
        [builder setScheme:@"https"];
        IMSRequest *request = [[builder setAuthenticationType:IMSAuthenticationTypeIoT] build];
        __weak typeof(self) weakSelf = self;
        [IMSRequestClient asyncSendRequest:request responseHandler:^(NSError * _Nullable error, IMSResponse * _Nullable response) {
              if (response.code == 401) {
                    [self loginOut];
                }
    
                if (error) {
                    NSLog(@"request error = %@",error);
                } else {
                    NSLog(@"request success");
                }
    
            }];
        }];
  5. 实时判断宿主App登录状态,示例代码如下。

    - (void)viewWillAppear:(BOOL)animated {
        [super viewWillAppear:animated];
    
        // 根据AppGroup共享区域存储的信息、配置Host、环境、语言、安全图片
        // 防止出现未打开宿主App、已经初始化Extension的APIClient
            [[IMSConfiguration sharedInstance] synchronizeConfigFromAppGroup];
    
            // 从UserDefaults更新Credential
            [[IMSCredentialManager sharedManager] synchronizeCredentialFromAppGroup];
    
        // 通过Credential是否存在来判断登录态
        if ([IMSCredentialManager sharedManager].credential) {
            // 已登录
        } else {
            // 未登录
        }
    }
  6. 配置小组件显示名称的多语言。

    1. 使用宿主App的 [IMSConfiguration sharedInstance].language更新语言信息,信息需要重新保存到group中。

      // 把ApiClient的信息,写入到对应的AppGroup共享区域中
      [[IMSConfiguration sharedInstance] storeConfigToAppGroup:AppGroupName];
    2. 设置多语言。

      选中TodayExtension的Target,选择 New > File > String File,新建 strings 文件(名称请使用InfoPlist)。

      创建完成后,选中InfoPlist.strings文件,单击Localize,添加多语言。

      设置多语言

      多语言设置后的界面如下。

      多语言

    3. 更改小组件的显示名称。

      选中某种语言,修改该语言下小组件的显示名称(小组件名字是系统语言控制的,这个不随App更改)。

      更改显示名称

设备小组件&场景小组件接口文档和调用过程

设备小组件和场景小组件在开发过程中使用的接口文档(参见场景服务)和调用示例如下。

  • 宿主App相关的接口

    • 场景小组件

      【1】获取已经被添加到小组件的场景list
          path:/living/appwidget/list
          version:1.0.0
          params:@{}
      【2】全量场景查询
          path:/living/scene/query
          version:1.0.1
          params = @{@"catalogId": @"0",
                                   @"pageNo": @(pageNo),
                                   @"pageSize": @(pageSize)
                                   }
      【3】更新场景小组件
        path:/living/appwidget/create
        version:1.0.0
        params = @{@"sceneIds": @[]}
    • 设备小组件

      【1】获取已经被加到小组件的设备list
          path:/iotx/ilop/queryComponentProduct
          version:1.0.0
          params:@{}
      
      【2】获取设备的属性列表(目前属性多语言需要入参时传递)
          path:/iotx/ilop/queryComponentProperty
          version:1.0.0
          params = @{@"productKey":productKey,
                                   @"iotId":iotId,
                                   @"query":@{@"dataType":@"BOOL”, @"I18Language":@"zh-CN"}
                                   }
      【3】小组件列表更新
          path:/iotx/ilop/updateComponentProduct
          version:1.0.0
          params:更改后的设备list
  • TodayExtension相关接口

    • 场景小组件

      【1】获取已经被添加到小组件的场景list
          path:/living/appwidget/list
          version:1.0.0
          params:@{}
      【2】执行场景
          path:/scene/fire
          version:1.0.1
          params:@{@"sceneId":sceneId}
    • 设备小组件

      【1】获取已经被加到小组件的设备list
          path:/iotx/ilop/queryComponentProduct
          version:1.0.0
          params:@{}    
      【2】设备小组件,有本地通信和云端通信逻辑,需要集成宿主APP中的长连接绑定 & 订阅,监听长连接正常连接
      【3】设备状态变更,需要自行定位/thing/properties 和  /thing/status 的topic,监听状态变更,刷新UI
      【4】选中设备,指定ThingShell设置设备属性,通过物的模型,变更属性
      【5】如果订阅过Topic,设置【4】成功后,也会收到云端的状态变更通知
    • 设备小组件核心参考代码

      【1】长连接绑定 & 订阅(相关SDK参见长连接通道SDK)
        IMSConfiguration * imsconfig = [IMSConfiguration sharedInstance];
        LKAEConnectConfig * config = [LKAEConnectConfig new];
        config.appKey = imsconfig.appKey;
        config.authCode = imsconfig.authCode;
        // 指定长连接服务器地址。 (默认不填,SDK会使用默认的地址及端口。默认为国内华东节点。不要带 "协议://",如果置为空,底层通道会使用默认的地址)
        config.server = @""
      // 开启动态选择Host功能。 (默认 NO,海外环境请设置为 YES。此功能前提为 config.server 不特殊指定。)
        config.autoSelectChannelHost = NO;
        [[LKAppExpress sharedInstance]startConnect:config connectListener:self];// self 需要实现 LKAppExpConnectListener 接口
      }
      【2】注册下行Listener
      
      #pragma mark - 注册下行Listener
      static NSString *const IMSiLopExtensionDidReceiveUpdateAttributeSuccess = @"LAMPPANEL_DIDRECEIVE_UPDATE_ATTRIBUTE_SUCCESS";
      static NSString *const IMSiLopExtensionDidReceiveUpdateDeviceStateSuccess = @"LAMPPANEL_DIDRECEIVE_UPDATE_DEVICE_STATE_SUCCESS";
      @class TodayViewController;
      @interface IMSWidgetDeviceListener : NSObject <LKAppExpDownListener>
      @end
      @implementation IMSWidgetDeviceListener
      
      - (void)onDownstream:(NSString * _Nonnull)topic data:(id  _Nullable)data {
          IMSAppExtensionLogVerbose(@"小组件 onDownstream topic : %@", topic);
          IMSAppExtensionLogVerbose(@"小组件 onDownstream data : %@", data);
          NSDictionary * replyDict = nil;
          if ([data isKindOfClass:[NSString class]]) {
              NSData * replyData = [data dataUsingEncoding:NSUTF8StringEncoding];
              replyDict = [NSJSONSerialization JSONObjectWithData:replyData options:NSJSONReadingMutableLeaves error:nil];
          } else if ([data isKindOfClass:[NSDictionary class]]) {
              replyDict = data;
              //这里添加云端处理!
              if (data) {
                  if ([topic isEqualToString:@"/thing/properties"]) {
                      [[NSNotificationCenter defaultCenter] postNotificationName:IMSiLopExtensionDidReceiveUpdateAttributeSuccess object:self userInfo:data];
                  }
      
                  if ([topic isEqualToString:@"/thing/status"]) {
                      [[NSNotificationCenter defaultCenter] postNotificationName:IMSiLopExtensionDidReceiveUpdateDeviceStateSuccess object:self userInfo:data];
                  }
      
              }
          }
          if (replyDict == nil) {
              return;
          }
      }
      
      - (BOOL)shouldHandle:(NSString * _Nonnull)topic {
          // 需要设什么topic,返回什么topic
          if ([topic isEqualToString:@"/thing/properties"] || [topic isEqualToString:@"/thing/status"]) {
              return YES;
          }
          return NO;
      }
      @end
      
      【3】增加代理监听、增加属性
      @interface IMSWidgetDeviceController () < LKAppExpConnectListener>
      
      // 本地控制
      @property (nonatomic, strong) IMSWidgetDeviceListener *imsWidgetDeviceListener;
      
      
      【4】在ViewDidLoad 中增加监听
      - (void)viewDidLoad {
          [super viewDidLoad];
          // Do any additional setup after loading the view from its nib.
      
          // 监听云端设备属性变更
          [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(dididReceiveUpdateAttributeNoti:) name:IMSiLopExtensionDidReceiveUpdateAttributeSuccess object:nil];
          // 监听云端设备状态变更
          [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(dididReceiveUpdateDeviceStateNoti:) name:IMSiLopExtensionDidReceiveUpdateDeviceStateSuccess object:nil];
      }
      
      【5】自行处理下行通知
      // 云端属性数据下发
      - (void)dididReceiveUpdateAttributeNoti:(NSNotification *)info {
      }
      
      // 云端状态数据下发
      - (void)dididReceiveUpdateDeviceStateNoti:(NSNotification *)info {
      }
      
      
      【6】更改属性方法
      IMSThing *thingShell = [kIMSThingManager buildThing:iotId];
      [[thingShell getThingActions] setProperties:@{propertyIdentifierName:value}
                                       responseHandler:^(IMSThingActionsResponse * _Nullable response) {
                                         if (response.success) {
                                               // 成功
                                           } else {
                                              // 失败
                                           }
      }];
      
      【7】释放资源
      - (void)viewWillDisappear:(BOOL)animated {
          [super viewWillDisappear:animated];
          // 移除长链接相关
          [[LKAppExpress sharedInstance] removeConnectListener:self];
          [[LKAppExpress sharedInstance] removeDownStreamListener:self.imsWidgetDeviceListener];
      }
      
      - (void)dealloc {
          [kIMSThingManager destroyThing:self.thingShell];
          [[NSNotificationCenter defaultCenter] removeObserver:self];
      }