前言

UniApp 是一个使用 Vue.js 开发跨平台应用的框架,但在实际项目中,我们经常需要调用原生能力来实现一些特殊功能,比如:

  • 调用硬件设备(蓝牙、NFC、生物识别等)
  • 集成第三方 SDK(支付、地图、推送等)
  • 性能敏感的计算任务
  • 访问系统级 API

本文将从零开始,详细讲解如何开发一个完整的 iOS 原生插件。


一、UniApp 插件架构解析

1.1 插件通信原理

┌─────────────────────────────────────────────────────────────────────┐
│                      UniApp 插件通信架构                              │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│   ┌──────────────────────────────────────────────────────────────┐  │
│   │                    JavaScript 层                              │  │
│   │  ┌────────────────────────────────────────────────────────┐  │  │
│   │  │  const module = uni.requireNativePlugin('PluginName')  │  │  │
│   │  │  module.methodName(params, callback)                   │  │  │
│   │  └────────────────────────────────────────────────────────┘  │  │
│   └──────────────────────────────────────────────────────────────┘  │
│                              │                                       │
│                              │ JSBridge                              │
│                              ▼                                       │
│   ┌──────────────────────────────────────────────────────────────┐  │
│   │                    Weex/DCL Bridge                            │  │
│   │  ┌────────────────────────────────────────────────────────┐  │  │
│   │  │  • 参数序列化/反序列化 (JSON)                           │  │  │
│   │  │  • 方法路由分发                                         │  │  │
│   │  │  • 回调管理                                             │  │  │
│   │  └────────────────────────────────────────────────────────┘  │  │
│   └──────────────────────────────────────────────────────────────┘  │
│                              │                                       │
│                              ▼                                       │
│   ┌──────────────────────────────────────────────────────────────┐  │
│   │                    Native 层 (iOS)                            │  │
│   │  ┌────────────────────────────────────────────────────────┐  │  │
│   │  │  Module 插件: 提供 API 方法                             │  │  │
│   │  │  Component 插件: 提供原生 UI 组件                       │  │  │
│   │  └────────────────────────────────────────────────────────┘  │  │
│   └──────────────────────────────────────────────────────────────┘  │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

1.2 插件类型对比

类型 用途 基类 场景示例
Module 提供 API 能力 DCUniModule 网络请求、文件操作、设备信息
Component 提供 UI 组件 DCUniComponent 自定义播放器、地图、图表

二、开发环境搭建

2.1 准备工作

下载 UniApp 离线 SDK
# 1. 访问 DCloud 官网下载 iOS 离线 SDK
# https://nativesupport.dcloud.net.cn/AppDocs/download/ios

# 2. 解压后目录结构
SDK/
├── HBuilder-Hello/          # 示例工程
├── HBuilder-uniPluginDemo/  # 插件开发示例
├── SDK/
│   ├── Libs/               # 核心库文件
│   ├── inc/                # 头文件
│   └── Resources/          # 资源文件
└── Feature-iOS.xls         # 功能配置表
创建插件工程
# 推荐的目录结构
MyPlugin/
├── ios/
│   ├── MyPlugin.xcodeproj
│   └── MyPlugin/
│       ├── MyPlugin.h
│       ├── MyPlugin.m
│       └── Info.plist
├── android/                 # Android 插件(可选)
├── package.json            # 插件配置
└── readme.md

2.2 Xcode 工程配置

创建 Framework 项目
  1. Xcode → File → New → Project
  2. 选择 Framework
  3. 命名为插件名称(如 DCTestModule
配置编译设置
Build Settings:
├── Architectures
│   └── Build Active Architecture Only → No
├── Build Options  
│   └── Enable Bitcode → No
├── Linking
│   └── Mach-O Type → Static Library (推荐) 或 Dynamic Library
└── Deployment
    └── iOS Deployment Target → 12.0 (与主工程一致)
引入 SDK 头文件
// 在插件头文件中引入
#import "DCUniModule.h"
#import "DCUniComponent.h"
#import "DCUniConvert.h"

// 或使用模块导入
@import DCUniPlugin;

三、Module 插件开发

3.1 基础 Module 实现

// DCTestModule.h
#import <Foundation/Foundation.h>
#import "DCUniModule.h"

NS_ASSUME_NONNULL_BEGIN

@interface DCTestModule : DCUniModule

@end

NS_ASSUME_NONNULL_END
// DCTestModule.m
#import "DCTestModule.h"

@implementation DCTestModule

#pragma mark - 同步方法
// 使用 UNI_EXPORT_METHOD_SYNC 导出同步方法
UNI_EXPORT_METHOD_SYNC(@selector(syncMethod:))
- (NSString *)syncMethod:(NSDictionary *)options {
    NSString *name = options[@"name"] ?: @"World";
    return [NSString stringWithFormat:@"Hello, %@!", name];
}

#pragma mark - 异步方法
// 使用 UNI_EXPORT_METHOD 导出异步方法
UNI_EXPORT_METHOD(@selector(asyncMethod:callback:))
- (void)asyncMethod:(NSDictionary *)options 
           callback:(UniModuleKeepAliveCallback)callback {
    
    NSString *message = options[@"message"] ?: @"default";
    
    // 模拟异步操作
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC), 
                   dispatch_get_main_queue(), ^{
        
        if (callback) {
            // 第二个参数表示是否保持回调(持续回调用 YES)
            callback(@{
                @"code": @0,
                @"message": @"success",
                @"data": message
            }, NO);
        }
    });
}

@end

3.2 高级功能实现

持续回调(事件监听)
// DCEventModule.m
#import "DCEventModule.h"
#import <CoreLocation/CoreLocation.h>

@interface DCEventModule () <CLLocationManagerDelegate>
@property (nonatomic, strong) CLLocationManager *locationManager;
@property (nonatomic, copy) UniModuleKeepAliveCallback locationCallback;
@end

@implementation DCEventModule

UNI_EXPORT_METHOD(@selector(startLocationUpdates:callback:))
- (void)startLocationUpdates:(NSDictionary *)options 
                    callback:(UniModuleKeepAliveCallback)callback {
    
    self.locationCallback = callback;
    
    if (!self.locationManager) {
        self.locationManager = [[CLLocationManager alloc] init];
        self.locationManager.delegate = self;
        self.locationManager.desiredAccuracy = kCLLocationAccuracyBest;
    }
    
    [self.locationManager requestWhenInUseAuthorization];
    [self.locationManager startUpdatingLocation];
}

UNI_EXPORT_METHOD(@selector(stopLocationUpdates))
- (void)stopLocationUpdates {
    [self.locationManager stopUpdatingLocation];
    
    // 最后一次回调,keepAlive = NO
    if (self.locationCallback) {
        self.locationCallback(@{
            @"code": @0,
            @"message": @"stopped"
        }, NO);
        self.locationCallback = nil;
    }
}

#pragma mark - CLLocationManagerDelegate

- (void)locationManager:(CLLocationManager *)manager 
     didUpdateLocations:(NSArray<CLLocation *> *)locations {
    
    CLLocation *location = locations.lastObject;
    
    if (self.locationCallback) {
        // keepAlive = YES 表示保持回调,后续还会继续调用
        self.locationCallback(@{
            @"code": @0,
            @"latitude": @(location.coordinate.latitude),
            @"longitude": @(location.coordinate.longitude),
            @"altitude": @(location.altitude),
            @"timestamp": @(location.timestamp.timeIntervalSince1970 * 1000)
        }, YES);  // ⚠️ 重要:YES 保持回调
    }
}

- (void)locationManager:(CLLocationManager *)manager 
       didFailWithError:(NSError *)error {
    
    if (self.locationCallback) {
        self.locationCallback(@{
            @"code": @(-1),
            @"message": error.localizedDescription
        }, NO);  // 错误时结束回调
        self.locationCallback = nil;
    }
}

@end
主动触发事件(globalEvent)
// DCEventEmitter.m
#import "DCEventEmitter.h"
#import "DCUniSDKInstance.h"
#import "WXSDKManager.h"

@implementation DCEventEmitter

UNI_EXPORT_METHOD(@selector(startListening))
- (void)startListening {
    // 注册通知监听
    [[NSNotificationCenter defaultCenter] addObserver:self 
                                             selector:@selector(handleAppNotification:) 
                                                 name:@"CustomAppEvent" 
                                               object:nil];
}

- (void)handleAppNotification:(NSNotification *)notification {
    // 向 JS 层发送全局事件
    [self fireGlobalEventWithName:@"customEvent" 
                           params:notification.userInfo ?: @{}];
}

// 发送全局事件的封装方法
- (void)fireGlobalEventWithName:(NSString *)eventName params:(NSDictionary *)params {
    if (self.uniInstance) {
        [self.uniInstance fireGlobalEvent:eventName params:params];
    }
}

- (void)dealloc {
    [[NSNotificationCenter defaultCenter] removeObserver:self];
}

@end

3.3 参数类型处理

// DCTypeModule.m - 各种参数类型的处理

@implementation DCTypeModule

UNI_EXPORT_METHOD(@selector(handleTypes:callback:))
- (void)handleTypes:(NSDictionary *)options 
           callback:(UniModuleKeepAliveCallback)callback {
    
    // 1. 基本类型
    NSString *stringValue = [DCUniConvert NSString:options[@"string"]];
    NSInteger intValue = [DCUniConvert NSInteger:options[@"integer"]];
    CGFloat floatValue = [DCUniConvert CGFloat:options[@"float"]];
    BOOL boolValue = [DCUniConvert BOOL:options[@"bool"]];
    
    // 2. 数组
    NSArray *arrayValue = options[@"array"];
    if ([arrayValue isKindOfClass:[NSArray class]]) {
        for (id item in arrayValue) {
            NSLog(@"Array item: %@", item);
        }
    }
    
    // 3. 字典
    NSDictionary *dictValue = options[@"dict"];
    if ([dictValue isKindOfClass:[NSDictionary class]]) {
        NSLog(@"Dict value: %@", dictValue);
    }
    
    // 4. Base64 数据
    NSString *base64String = options[@"base64Data"];
    if (base64String) {
        NSData *data = [[NSData alloc] initWithBase64EncodedString:base64String 
                                                          options:0];
        NSLog(@"Data length: %lu", (unsigned long)data.length);
    }
    
    // 5. 日期处理
    NSNumber *timestamp = options[@"timestamp"];
    if (timestamp) {
        NSDate *date = [NSDate dateWithTimeIntervalSince1970:timestamp.doubleValue / 1000.0];
        NSLog(@"Date: %@", date);
    }
    
    // 返回结果
    callback(@{
        @"code": @0,
        @"result": @{
            @"string": stringValue ?: @"",
            @"int": @(intValue),
            @"float": @(floatValue),
            @"bool": @(boolValue)
        }
    }, NO);
}

@end

四、Component 插件开发

4.1 基础 Component 实现

// DCTestComponent.h
#import "DCUniComponent.h"

NS_ASSUME_NONNULL_BEGIN

@interface DCTestComponent : DCUniComponent

@end

NS_ASSUME_NONNULL_END
// DCTestComponent.m
#import "DCTestComponent.h"

@interface DCTestComponent ()
@property (nonatomic, strong) UILabel *contentLabel;
@property (nonatomic, strong) NSString *text;
@property (nonatomic, strong) UIColor *textColor;
@property (nonatomic, assign) CGFloat fontSize;
@end

@implementation DCTestComponent

#pragma mark - 生命周期

// 初始化方法,解析 JS 传入的属性
- (instancetype)initWithRef:(NSString *)ref 
                       type:(NSString *)type 
                     styles:(NSDictionary *)styles 
                 attributes:(NSDictionary *)attributes 
                     events:(NSArray *)events 
               uniInstance:(DCUniSDKInstance *)uniInstance {
    
    self = [super initWithRef:ref 
                         type:type 
                       styles:styles 
                   attributes:attributes 
                       events:events 
                 uniInstance:uniInstance];
    
    if (self) {
        // 解析属性
        _text = attributes[@"text"] ?: @"";
        _textColor = [DCUniConvert UIColor:styles[@"color"]] ?: [UIColor blackColor];
        _fontSize = styles[@"fontSize"] ? [DCUniConvert CGFloat:styles[@"fontSize"]] : 16.0;
    }
    
    return self;
}

// 创建视图
- (UIView *)loadView {
    UIView *containerView = [[UIView alloc] init];
    containerView.backgroundColor = [UIColor clearColor];
    return containerView;
}

// 视图加载完成后的配置
- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.contentLabel = [[UILabel alloc] init];
    self.contentLabel.text = self.text;
    self.contentLabel.textColor = self.textColor;
    self.contentLabel.font = [UIFont systemFontOfSize:self.fontSize];
    self.contentLabel.textAlignment = NSTextAlignmentCenter;
    [self.view addSubview:self.contentLabel];
}

// 布局
- (void)layoutDidFinish {
    [super layoutDidFinish];
    self.contentLabel.frame = self.view.bounds;
}

#pragma mark - 属性更新

// 当 JS 更新属性时调用
- (void)updateAttributes:(NSDictionary *)attributes {
    [super updateAttributes:attributes];
    
    if (attributes[@"text"]) {
        self.text = attributes[@"text"];
        self.contentLabel.text = self.text;
    }
}

// 当 JS 更新样式时调用
- (void)updateStyles:(NSDictionary *)styles {
    [super updateStyles:styles];
    
    if (styles[@"color"]) {
        self.textColor = [DCUniConvert UIColor:styles[@"color"]];
        self.contentLabel.textColor = self.textColor;
    }
    
    if (styles[@"fontSize"]) {
        self.fontSize = [DCUniConvert CGFloat:styles[@"fontSize"]];
        self.contentLabel.font = [UIFont systemFontOfSize:self.fontSize];
    }
}

#pragma mark - 导出方法供 JS 调用

UNI_EXPORT_METHOD(@selector(setText:))
- (void)setText:(NSString *)text {
    self.text = text;
    dispatch_async(dispatch_get_main_queue(), ^{
        self.contentLabel.text = text;
    });
}

UNI_EXPORT_METHOD(@selector(flash))
- (void)flash {
    dispatch_async(dispatch_get_main_queue(), ^{
        [UIView animateWithDuration:0.3 animations:^{
            self.contentLabel.alpha = 0;
        } completion:^(BOOL finished) {
            [UIView animateWithDuration:0.3 animations:^{
                self.contentLabel.alpha = 1;
            }];
        }];
    });
}

#pragma mark - 事件

// 添加事件监听
- (void)addEvent:(NSString *)eventName {
    [super addEvent:eventName];
    
    if ([eventName isEqualToString:@"tap"]) {
        UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] 
                                        initWithTarget:self 
                                                action:@selector(handleTap:)];
        [self.view addGestureRecognizer:tap];
    }
}

- (void)handleTap:(UITapGestureRecognizer *)gesture {
    CGPoint point = [gesture locationInView:self.view];
    
    // 触发事件到 JS 层
    [self fireEvent:@"tap" params:@{
        @"x": @(point.x),
        @"y": @(point.y),
        @"text": self.text ?: @""
    }];
}

@end

4.2 复杂组件示例:视频播放器

// DCVideoComponent.h
#import "DCUniComponent.h"
#import <AVFoundation/AVFoundation.h>

@interface DCVideoComponent : DCUniComponent

@end
// DCVideoComponent.m
#import "DCVideoComponent.h"

@interface DCVideoComponent ()
@property (nonatomic, strong) AVPlayer *player;
@property (nonatomic, strong) AVPlayerLayer *playerLayer;
@property (nonatomic, strong) id timeObserver;
@property (nonatomic, copy) NSString *src;
@property (nonatomic, assign) BOOL autoplay;
@property (nonatomic, assign) BOOL loop;
@property (nonatomic, assign) BOOL muted;
@end

@implementation DCVideoComponent

- (instancetype)initWithRef:(NSString *)ref 
                       type:(NSString *)type 
                     styles:(NSDictionary *)styles 
                 attributes:(NSDictionary *)attributes 
                     events:(NSArray *)events 
               uniInstance:(DCUniSDKInstance *)uniInstance {
    
    self = [super initWithRef:ref type:type styles:styles 
                   attributes:attributes events:events uniInstance:uniInstance];
    
    if (self) {
        _src = attributes[@"src"];
        _autoplay = [DCUniConvert BOOL:attributes[@"autoplay"]];
        _loop = [DCUniConvert BOOL:attributes[@"loop"]];
        _muted = [DCUniConvert BOOL:attributes[@"muted"]];
    }
    
    return self;
}

- (UIView *)loadView {
    UIView *view = [[UIView alloc] init];
    view.backgroundColor = [UIColor blackColor];
    return view;
}

- (void)viewDidLoad {
    [super viewDidLoad];
    
    if (self.src) {
        [self setupPlayer];
    }
}

- (void)setupPlayer {
    NSURL *url = [NSURL URLWithString:self.src];
    AVPlayerItem *playerItem = [AVPlayerItem playerItemWithURL:url];
    
    self.player = [AVPlayer playerWithPlayerItem:playerItem];
    self.player.muted = self.muted;
    
    self.playerLayer = [AVPlayerLayer playerLayerWithPlayer:self.player];
    self.playerLayer.videoGravity = AVLayerVideoGravityResizeAspect;
    [self.view.layer addSublayer:self.playerLayer];
    
    // 监听播放状态
    [playerItem addObserver:self 
                 forKeyPath:@"status" 
                    options:NSKeyValueObservingOptionNew 
                    context:nil];
    
    // 监听播放结束
    [[NSNotificationCenter defaultCenter] addObserver:self 
                                             selector:@selector(playerDidFinish:) 
                                                 name:AVPlayerItemDidPlayToEndTimeNotification 
                                               object:playerItem];
    
    // 添加时间观察者
    __weak typeof(self) weakSelf = self;
    self.timeObserver = [self.player addPeriodicTimeObserverForInterval:CMTimeMake(1, 10) 
                                                                  queue:dispatch_get_main_queue() 
                                                             usingBlock:^(CMTime time) {
        [weakSelf handleTimeUpdate:time];
    }];
    
    if (self.autoplay) {
        [self.player play];
    }
}

- (void)layoutDidFinish {
    [super layoutDidFinish];
    self.playerLayer.frame = self.view.bounds;
}

#pragma mark - 属性更新

- (void)updateAttributes:(NSDictionary *)attributes {
    [super updateAttributes:attributes];
    
    if (attributes[@"src"] && ![attributes[@"src"] isEqualToString:self.src]) {
        self.src = attributes[@"src"];
        [self replacePlayerItem];
    }
    
    if (attributes[@"muted"]) {
        self.muted = [DCUniConvert BOOL:attributes[@"muted"]];
        self.player.muted = self.muted;
    }
}

- (void)replacePlayerItem {
    NSURL *url = [NSURL URLWithString:self.src];
    AVPlayerItem *newItem = [AVPlayerItem playerItemWithURL:url];
    [self.player replaceCurrentItemWithPlayerItem:newItem];
}

#pragma mark - 导出方法

UNI_EXPORT_METHOD(@selector(play))
- (void)play {
    dispatch_async(dispatch_get_main_queue(), ^{
        [self.player play];
        [self fireEvent:@"play" params:@{}];
    });
}

UNI_EXPORT_METHOD(@selector(pause))
- (void)pause {
    dispatch_async(dispatch_get_main_queue(), ^{
        [self.player pause];
        [self fireEvent:@"pause" params:@{}];
    });
}

UNI_EXPORT_METHOD(@selector(seek:))
- (void)seek:(NSDictionary *)options {
    CGFloat position = [DCUniConvert CGFloat:options[@"position"]];
    CMTime time = CMTimeMakeWithSeconds(position, NSEC_PER_SEC);
    
    __weak typeof(self) weakSelf = self;
    [self.player seekToTime:time completionHandler:^(BOOL finished) {
        if (finished) {
            [weakSelf fireEvent:@"seeked" params:@{@"position": @(position)}];
        }
    }];
}

UNI_EXPORT_METHOD_SYNC(@selector(getCurrentTime))
- (NSDictionary *)getCurrentTime {
    CMTime time = self.player.currentTime;
    Float64 seconds = CMTimeGetSeconds(time);
    return @{@"currentTime": @(seconds)};
}

#pragma mark - 事件处理

- (void)handleTimeUpdate:(CMTime)time {
    Float64 currentTime = CMTimeGetSeconds(time);
    Float64 duration = CMTimeGetSeconds(self.player.currentItem.duration);
    
    [self fireEvent:@"timeupdate" params:@{
        @"currentTime": @(currentTime),
        @"duration": @(isnan(duration) ? 0 : duration)
    }];
}

- (void)playerDidFinish:(NSNotification *)notification {
    [self fireEvent:@"ended" params:@{}];
    
    if (self.loop) {
        [self.player seekToTime:kCMTimeZero];
        [self.player play];
    }
}

- (void)observeValueForKeyPath:(NSString *)keyPath 
                      ofObject:(id)object 
                        change:(NSDictionary<NSKeyValueChangeKey,id> *)change 
                       context:(void *)context {
    
    if ([keyPath isEqualToString:@"status"]) {
        AVPlayerItemStatus status = [change[NSKeyValueChangeNewKey] integerValue];
        
        switch (status) {
            case AVPlayerItemStatusReadyToPlay:
                [self fireEvent:@"canplay" params:@{}];
                break;
            case AVPlayerItemStatusFailed:
                [self fireEvent:@"error" params:@{
                    @"message": self.player.currentItem.error.localizedDescription ?: @"Unknown error"
                }];
                break;
            default:
                break;
        }
    }
}

#pragma mark - 清理

- (void)viewWillUnload {
    [super viewWillUnload];
    
    [self.player pause];
    
    if (self.timeObserver) {
        [self.player removeTimeObserver:self.timeObserver];
        self.timeObserver = nil;
    }
    
    [self.player.currentItem removeObserver:self forKeyPath:@"status"];
    [[NSNotificationCenter defaultCenter] removeObserver:self];
}

@end

五、插件配置与打包

5.1 package.json 配置

{
    "name": "DCTestPlugin",
    "id": "DCTestPlugin",
    "version": "1.0.0",
    "description": "A test plugin for UniApp",
    "_dp_type": "nativeplugin",
    "_dp_nativeplugin": {
        "ios": {
            "plugins": [
                {
                    "type": "module",
                    "name": "DCTestModule",
                    "class": "DCTestModule"
                },
                {
                    "type": "module",
                    "name": "DCEventModule",
                    "class": "DCEventModule"
                },
                {
                    "type": "component",
                    "name": "dc-video",
                    "class": "DCVideoComponent"
                }
            ],
            "integrateType": "framework",
            "deploymentTarget": "12.0",
            "frameworks": [
                "AVFoundation.framework",
                "CoreLocation.framework"
            ],
            "embedFrameworks": [],
            "capabilities": {
                "entitlements": {}
            },
            "plists": {
                "NSLocationWhenInUseUsageDescription": "需要访问您的位置信息",
                "NSCameraUsageDescription": "需要访问您的相机"
            },
            "resources": [
                "Resources/*.bundle"
            ],
            "parameters": {
                "apiKey": {
                    "des": "API密钥",
                    "key": "DCTestPlugin:apiKey"
                }
            },
            "dependencies": [
                "pod 'AFNetworking', '~> 4.0'"
            ]
        }
    }
}

5.2 插件目录结构

DCTestPlugin/
├── android/                          # Android 插件目录
├── ios/
│   ├── DCTestPlugin.framework        # 编译后的 Framework
│   └── DCTestPlugin.podspec          # CocoaPods 规范(可选)
├── package.json                       # 插件配置文件
├── readme.md                          # 使用说明
└── example/                           # 示例代码
    └── pages/
        └── test/
            └── test.vue

5.3 本地调试配置

在 HBuilderX 项目中配置本地插件:

项目目录/
├── nativeplugins/
│   └── DCTestPlugin/
│       ├── ios/
│       │   └── DCTestPlugin.framework
│       ├── android/
│       └── package.json
├── manifest.json
└── pages/

manifest.json 配置

{
    "app-plus": {
        "nativePlugins": [
            {
                "name": "DCTestPlugin",
                "class": "DCTestModule",
                "type": "local"
            }
        ]
    }
}

六、JavaScript 调用示例

6.1 Module 调用

// test.vue
<template>
    <view class="container">
        <button @click="testSync">测试同步方法</button>
        <button @click="testAsync">测试异步方法</button>
        <button @click="startLocation">开始定位</button>
        <button @click="stopLocation">停止定位</button>
        
        <view class="result">{{ result }}</view>
    </view>
</template>

<script>
// 引入原生插件
const testModule = uni.requireNativePlugin('DCTestModule')
const eventModule = uni.requireNativePlugin('DCEventModule')

export default {
    data() {
        return {
            result: ''
        }
    },
    
    onLoad() {
        // 监听全局事件
        uni.$on('customEvent', this.handleCustomEvent)
    },
    
    onUnload() {
        uni.$off('customEvent', this.handleCustomEvent)
        this.stopLocation()
    },
    
    methods: {
        // 同步方法调用
        testSync() {
            const result = testModule.syncMethod({
                name: 'UniApp'
            })
            this.result = `同步结果: ${result}`
            console.log('Sync result:', result)
        },
        
        // 异步方法调用
        testAsync() {
            testModule.asyncMethod({
                message: 'Hello Native!'
            }, (res) => {
                console.log('Async callback:', res)
                if (res.code === 0) {
                    this.result = `异步结果: ${res.data}`
                } else {
                    this.result = `错误: ${res.message}`
                }
            })
        },
        
        // 持续回调示例
        startLocation() {
            eventModule.startLocationUpdates({
                accuracy: 'high'
            }, (res) => {
                console.log('Location update:', res)
                if (res.code === 0 && res.latitude) {
                    this.result = `位置: ${res.latitude}, ${res.longitude}`
                }
            })
        },
        
        stopLocation() {
            eventModule.stopLocationUpdates()
        },
        
        // 全局事件处理
        handleCustomEvent(data) {
            console.log('Received custom event:', data)
            this.result = `事件数据: ${JSON.stringify(data)}`
        }
    }
}
</script>

6.2 Component 调用

<!-- video-test.vue -->
<template>
    <view class="container">
        <!-- 使用原生组件 -->
        <dc-video
            ref="videoPlayer"
            :src="videoUrl"
            :autoplay="false"
            :loop="false"
            :muted="false"
            :style="videoStyle"
            @play="onPlay"
            @pause="onPause"
            @timeupdate="onTimeUpdate"
            @ended="onEnded"
            @error="onError"
        />
        
        <view class="controls">
            <button @click="play">播放</button>
            <button @click="pause">暂停</button>
            <button @click="seekTo(30)">跳转到30s</button>
        </view>
        
        <view class="progress">
            <text>{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</text>
            <slider 
                :value="progress" 
                :max="100" 
                @change="onSliderChange"
            />
        </view>
    </view>
</template>

<script>
export default {
    data() {
        return {
            videoUrl: 'https://example.com/video.mp4',
            videoStyle: {
                width: '750rpx',
                height: '422rpx'
            },
            currentTime: 0,
            duration: 0
        }
    },
    
    computed: {
        progress() {
            if (this.duration === 0) return 0
            return (this.currentTime / this.duration) * 100
        }
    },
    
    methods: {
        // 调用组件方法
        play() {
            this.$refs.videoPlayer.play()
        },
        
        pause() {
            this.$refs.videoPlayer.pause()
        },
        
        seekTo(seconds) {
            this.$refs.videoPlayer.seek({
                position: seconds
            })
        },
        
        // 事件处理
        onPlay(e) {
            console.log('Video started playing')
        },
        
        onPause(e) {
            console.log('Video paused')
        },
        
        onTimeUpdate(e) {
            this.currentTime = e.detail.currentTime
            this.duration = e.detail.duration
        },
        
        onEnded(e) {
            console.log('Video ended')
        },
        
        onError(e) {
            console.error('Video error:', e.detail.message)
            uni.showToast({
                title: '播放出错',
                icon: 'none'
            })
        },
        
        onSliderChange(e) {
            const position = (e.detail.value / 100) * this.duration
            this.seekTo(position)
        },
        
        formatTime(seconds) {
            const mins = Math.floor(seconds / 60)
            const secs = Math.floor(seconds % 60)
            return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
        }
    }
}
</script>

<style>
.container {
    flex: 1;
    background-color: #000;
}

.controls {
    flex-direction: row;
    justify-content: center;
    padding: 20rpx;
}

.progress {
    padding: 20rpx;
}
</style>

七、实战案例:生物识别插件

7.1 完整实现

// DCBiometricModule.h
#import "DCUniModule.h"

@interface DCBiometricModule : DCUniModule

@end
// DCBiometricModule.m
#import "DCBiometricModule.h"
#import <LocalAuthentication/LocalAuthentication.h>

@implementation DCBiometricModule

#pragma mark - 检查生物识别可用性

UNI_EXPORT_METHOD_SYNC(@selector(checkBiometricAvailable))
- (NSDictionary *)checkBiometricAvailable {
    LAContext *context = [[LAContext alloc] init];
    NSError *error = nil;
    
    BOOL canEvaluate = [context canEvaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics 
                                            error:&error];
    
    NSString *biometricType = @"none";
    if (@available(iOS 11.0, *)) {
        switch (context.biometryType) {
            case LABiometryTypeTouchID:
                biometricType = @"touchID";
                break;
            case LABiometryTypeFaceID:
                biometricType = @"faceID";
                break;
            default:
                biometricType = @"none";
                break;
        }
    } else {
        if (canEvaluate) {
            biometricType = @"touchID";
        }
    }
    
    return @{
        @"available": @(canEvaluate),
        @"biometricType": biometricType,
        @"errorCode": @(error ? error.code : 0),
        @"errorMessage": error.localizedDescription ?: @""
    };
}

#pragma mark - 执行生物识别验证

UNI_EXPORT_METHOD(@selector(authenticate:callback:))
- (void)authenticate:(NSDictionary *)options 
            callback:(UniModuleKeepAliveCallback)callback {
    
    NSString *reason = options[@"reason"] ?: @"请验证身份";
    BOOL allowFallback = options[@"allowFallback"] ? [DCUniConvert BOOL:options[@"allowFallback"]] : YES;
    
    LAContext *context = [[LAContext alloc] init];
    
    // 配置是否允许回退到密码
    if (!allowFallback) {
        context.localizedFallbackTitle = @"";
    } else {
        context.localizedFallbackTitle = options[@"fallbackTitle"] ?: @"使用密码";
    }
    
    // 自定义取消按钮文字
    if (@available(iOS 10.0, *)) {
        context.localizedCancelTitle = options[@"cancelTitle"] ?: @"取消";
    }
    
    // 验证策略
    LAPolicy policy = LAPolicyDeviceOwnerAuthenticationWithBiometrics;
    if (options[@"allowDeviceCredential"] && [DCUniConvert BOOL:options[@"allowDeviceCredential"]]) {
        if (@available(iOS 9.0, *)) {
            policy = LAPolicyDeviceOwnerAuthentication;
        }
    }
    
    // 执行验证
    [context evaluatePolicy:policy 
            localizedReason:reason 
                      reply:^(BOOL success, NSError * _Nullable error) {
        
        dispatch_async(dispatch_get_main_queue(), ^{
            if (success) {
                callback(@{
                    @"success": @YES,
                    @"code": @0,
                    @"message": @"验证成功"
                }, NO);
            } else {
                NSString *errorMessage = [self getErrorMessage:error];
                NSInteger errorCode = [self getErrorCode:error];
                
                callback(@{
                    @"success": @NO,
                    @"code": @(errorCode),
                    @"message": errorMessage,
                    @"nativeErrorCode": @(error.code)
                }, NO);
            }
        });
    }];
}

#pragma mark - 错误处理

- (NSString *)getErrorMessage:(NSError *)error {
    if (!error) return @"未知错误";
    
    switch (error.code) {
        case LAErrorAuthenticationFailed:
            return @"验证失败";
        case LAErrorUserCancel:
            return @"用户取消";
        case LAErrorUserFallback:
            return @"用户选择使用密码";
        case LAErrorSystemCancel:
            return @"系统取消(如来电等)";
        case LAErrorPasscodeNotSet:
            return @"设备未设置密码";
        case LAErrorBiometryNotAvailable:
            return @"生物识别不可用";
        case LAErrorBiometryNotEnrolled:
            return @"未录入生物识别信息";
        case LAErrorBiometryLockout:
            return @"生物识别已锁定,请使用密码";
        default:
            return error.localizedDescription ?: @"验证错误";
    }
}

- (NSInteger)getErrorCode:(NSError *)error {
    if (!error) return -999;
    
    switch (error.code) {
        case LAErrorAuthenticationFailed:
            return 1;
        case LAErrorUserCancel:
            return 2;
        case LAErrorUserFallback:
            return 3;
        case LAErrorSystemCancel:
            return 4;
        case LAErrorPasscodeNotSet:
            return 5;
        case LAErrorBiometryNotAvailable:
            return 6;
        case LAErrorBiometryNotEnrolled:
            return 7;
        case LAErrorBiometryLockout:
            return 8;
        default:
            return -1;
    }
}

#pragma mark - 取消验证

UNI_EXPORT_METHOD(@selector(cancelAuthentication))
- (void)cancelAuthentication {
    // LAContext 没有取消方法,需要通过其他方式处理
    // 通常通过销毁 context 来实现
}

@end

7.2 JavaScript 封装

// utils/biometric.js

const biometricModule = uni.requireNativePlugin('DCBiometricModule')

class BiometricAuth {
    /**
     * 检查生物识别是否可用
     */
    static checkAvailable() {
        return new Promise((resolve) => {
            const result = biometricModule.checkBiometricAvailable()
            resolve({
                available: result.available,
                type: result.biometricType, // 'faceID' | 'touchID' | 'none'
                error: result.errorCode !== 0 ? result.errorMessage : null
            })
        })
    }
    
    /**
     * 执行生物识别验证
     * @param {Object} options 配置选项
     * @param {string} options.reason 验证原因描述
     * @param {boolean} options.allowFallback 是否允许回退到密码
     * @param {string} options.fallbackTitle 回退按钮文字
     * @param {string} options.cancelTitle 取消按钮文字
     */
    static authenticate(options = {}) {
        return new Promise((resolve, reject) => {
            const defaultOptions = {
                reason: '请验证身份以继续',
                allowFallback: true,
                fallbackTitle: '使用密码',
                cancelTitle: '取消'
            }
            
            const finalOptions = { ...defaultOptions, ...options }
            
            biometricModule.authenticate(finalOptions, (result) => {
                if (result.success) {
                    resolve(result)
                } else {
                    reject({
                        code: result.code,
                        message: result.message,
                        isCancelled: result.code === 2,
                        isLocked: result.code === 8
                    })
                }
            })
        })
    }
    
    /**
     * 获取生物识别类型的显示名称
     */
    static getBiometricName(type) {
        const names = {
            'faceID': 'Face ID',
            'touchID': 'Touch ID',
            'none': '无'
        }
        return names[type] || '生物识别'
    }
}

export default BiometricAuth

7.3 使用示例

<template>
    <view class="container">
        <view class="status" v-if="biometricStatus">
            <text>生物识别状态: {{ biometricStatus.available ? '可用' : '不可用' }}</text>
            <text v-if="biometricStatus.available">
                类型: {{ biometricName }}
            </text>
        </view>
        
        <button 
            class="auth-btn"
            :disabled="!biometricStatus?.available"
            @click="handleAuthenticate"
        >
            使用 {{ biometricName }} 验证
        </button>
        
        <view class="result" v-if="authResult">
            <text :class="authResult.success ? 'success' : 'error'">
                {{ authResult.message }}
            </text>
        </view>
    </view>
</template>

<script>
import BiometricAuth from '@/utils/biometric.js'

export default {
    data() {
        return {
            biometricStatus: null,
            authResult: null
        }
    },
    
    computed: {
        biometricName() {
            if (!this.biometricStatus) return ''
            return BiometricAuth.getBiometricName(this.biometricStatus.type)
        }
    },
    
    async onLoad() {
        await this.checkBiometric()
    },
    
    methods: {
        async checkBiometric() {
            try {
                this.biometricStatus = await BiometricAuth.checkAvailable()
                console.log('Biometric status:', this.biometricStatus)
            } catch (e) {
                console.error('Check biometric error:', e)
            }
        },
        
        async handleAuthenticate() {
            try {
                const result = await BiometricAuth.authenticate({
                    reason: '验证身份以访问敏感信息'
                })
                
                this.authResult = {
                    success: true,
                    message: '验证成功!'
                }
                
                // 验证成功后的业务逻辑
                this.onAuthSuccess()
                
            } catch (error) {
                console.log('Auth error:', error)
                
                if (error.isCancelled) {
                    this.authResult = {
                        success: false,
                        message: '已取消验证'
                    }
                } else if (error.isLocked) {
                    this.authResult = {
                        success: false,
                        message: '验证已锁定,请稍后再试'
                    }
                    
                    uni.showModal({
                        title: '提示',
                        content: '生物识别已锁定,请使用密码解锁后重试',
                        showCancel: false
                    })
                } else {
                    this.authResult = {
                        success: false,
                        message: error.message
                    }
                }
            }
        },
        
        onAuthSuccess() {
            // 执行验证成功后的操作
            uni.showToast({
                title: '验证成功',
                icon: 'success'
            })
        }
    }
}
</script>

八、调试与问题排查

8.1 常见问题

问题1:插件无法加载
// 检查插件是否正确加载
const module = uni.requireNativePlugin('DCTestModule')
console.log('Module:', module)  // 如果为 undefined,说明插件未正确加载

// 可能的原因:
// 1. package.json 配置错误
// 2. Framework 未正确编译或导入
// 3. 类名与配置不匹配
问题2:方法调用无响应
// 确保使用正确的宏导出方法
// 同步方法
UNI_EXPORT_METHOD_SYNC(@selector(syncMethod:))

// 异步方法
UNI_EXPORT_METHOD(@selector(asyncMethod:callback:))

// 常见错误:方法签名不正确
// ❌ 错误
- (void)asyncMethod:(NSDictionary *)options :(UniModuleKeepAliveCallback)callback

// ✅ 正确
- (void)asyncMethod:(NSDictionary *)options callback:(UniModuleKeepAliveCallback)callback
问题3:回调无法触发
// 确保在主线程回调
- (void)someMethod:(NSDictionary *)options callback:(UniModuleKeepAliveCallback)callback {
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        // 异步处理
        id result = [self doSomething];
        
        // ⚠️ 回调必须在适当线程
        dispatch_async(dispatch_get_main_queue(), ^{
            if (callback) {
                callback(@{@"result": result}, NO);
            }
        });
    });
}

8.2 调试技巧

// 1. 添加日志
- (void)testMethod:(NSDictionary *)options callback:(UniModuleKeepAliveCallback)callback {
    NSLog(@"[DCTestModule] testMethod called with options: %@", options);
    
    // 处理逻辑...
    
    NSLog(@"[DCTestModule] testMethod completed");
}

// 2. 使用断言检查
- (void)safeMethod:(NSDictionary *)options callback:(UniModuleKeepAliveCallback)callback {
    NSAssert(options != nil, @"Options should not be nil");
    NSAssert(callback != nil, @"Callback should not be nil");
    
    // 处理逻辑...
}

// 3. 错误处理
- (void)robustMethod:(NSDictionary *)options callback:(UniModuleKeepAliveCallback)callback {
    @try {
        // 可能抛出异常的代码
        [self riskyOperation:options];
        
        callback(@{@"code": @0, @"message": @"success"}, NO);
    }
    @catch (NSException *exception) {
        NSLog(@"[DCTestModule] Exception: %@", exception);
        
        callback(@{
            @"code": @(-1),
            @"message": exception.reason ?: @"Unknown error"
        }, NO);
    }
}

8.3 性能监控

// 方法执行时间统计
- (void)performanceTest:(NSDictionary *)options callback:(UniModuleKeepAliveCallback)callback {
    CFAbsoluteTime startTime = CFAbsoluteTimeGetCurrent();
    
    // 执行操作
    [self doHeavyWork];
    
    CFAbsoluteTime endTime = CFAbsoluteTimeGetCurrent();
    NSTimeInterval duration = endTime - startTime;
    
    NSLog(@"[Performance] Method took %.4f seconds", duration);
    
    callback(@{
        @"code": @0,
        @"duration": @(duration * 1000)  // 毫秒
    }, NO);
}

九、发布与分发

9.1 打包 Framework

# 1. 选择 Generic iOS Device
# 2. Build (⌘B)
# 3. 在 Products 文件夹找到 .framework 文件

# 或使用脚本打包 Universal Framework
xcodebuild -scheme DCTestPlugin -configuration Release -sdk iphoneos
xcodebuild -scheme DCTestPlugin -configuration Release -sdk iphonesimulator

# 合并
lipo -create \
    build/Release-iphoneos/DCTestPlugin.framework/DCTestPlugin \
    build/Release-iphonesimulator/DCTestPlugin.framework/DCTestPlugin \
    -output DCTestPlugin.framework/DCTestPlugin

9.2 提交到 DCloud 插件市场

  1. 准备完整的插件目录结构
  2. 编写详细的 readme.md
  3. 提供示例代码
  4. DCloud 插件市场 提交审核

9.3 私有分发

// package.json 添加私有配置
{
    "name": "DCPrivatePlugin",
    "id": "DCPrivatePlugin",
    "version": "1.0.0",
    "private": true,
    "_dp_nativeplugin": {
        "ios": {
            // ... 配置
        }
    }
}

十、最佳实践总结

10.1 代码规范

// 1. 统一的错误响应格式
NSDictionary *successResponse = @{
    @"code": @0,
    @"message": @"success",
    @"data": resultData
};

NSDictionary *errorResponse = @{
    @"code": @(-1),
    @"message": @"error description",
    @"data": [NSNull null]
};

// 2. 参数校验
- (void)safeMethod:(NSDictionary *)options callback:(UniModuleKeepAliveCallback)callback {
    // 必需参数校验
    NSString *requiredParam = options[@"required"];
    if (!requiredParam || ![requiredParam isKindOfClass:[NSString class]]) {
        callback(@{
            @"code": @(-2),
            @"message": @"Missing required parameter: required"
        }, NO);
        return;
    }
    
    // 可选参数默认值
    NSString *optionalParam = options[@"optional"] ?: @"default";
    
    // 继续处理...
}

// 3. 线程安全
@property (nonatomic, strong) dispatch_queue_t workQueue;

- (instancetype)init {
    if (self = [super init]) {
        _workQueue = dispatch_queue_create("com.plugin.workqueue", DISPATCH_QUEUE_SERIAL);
    }
    return self;
}

10.2 内存管理

// 1. 避免循环引用
__weak typeof(self) weakSelf = self;
[self.manager startWithCompletion:^(id result) {
    __strong typeof(weakSelf) strongSelf = weakSelf;
    if (!strongSelf) return;
    
    [strongSelf handleResult:result];
}];

// 2. 资源释放
- (void)dealloc {
    [self cleanup];
    [[NSNotificationCenter defaultCenter] removeObserver:self];
    NSLog(@"[DCTestModule] dealloc");
}

- (void)cleanup {
    [self.timer invalidate];
    self.timer = nil;
    
    [self.task cancel];
    self.task = nil;
}

10.3 安全性考虑

// 1. 敏感数据处理
- (void)handleSensitiveData:(NSDictionary *)options callback:(UniModuleKeepAliveCallback)callback {
    NSString *token = options[@"token"];
    
    // 不要在日志中打印敏感数据
    #ifdef DEBUG
    NSLog(@"[DEBUG] Token received (length: %lu)", (unsigned long)token.length);
    #endif
    
    // 使用后及时清理
    // ...
}

// 2. 输入验证
- (BOOL)validateURL:(NSString *)urlString {
    if (!urlString || urlString.length == 0) return NO;
    
    NSURL *url = [NSURL URLWithString:urlString];
    if (!url) return NO;
    
    // 只允许 https
    if (![url.scheme isEqualToString:@"https"]) return NO;
    
    // 可以添加域名白名单
    NSArray *allowedHosts = @[@"api.example.com", @"cdn.example.com"];
    return [allowedHosts containsObject:url.host];
}

总结

本文详细介绍了 iOS 原生 UniApp 插件开发的完整流程:

  1. 基础概念:理解插件通信架构和类型区别
  2. 环境搭建:配置开发环境和工程结构
  3. Module 开发:同步/异步方法、持续回调、全局事件
  4. Component 开发:自定义视图组件的生命周期和交互
  5. 配置打包:package.json 配置和 Framework 打包
  6. 实战案例:生物识别插件的完整实现
  7. 调试技巧:常见问题排查和性能监控
  8. 最佳实践:代码规范、内存管理、安全性

掌握这些知识,你就能够开发出高质量的 UniApp 原生插件,为跨平台应用提供强大的原生能力支持。


参考资料

Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐