书中有些步骤已经失效,所以我们按照官方安装Wiki来
brew install ldid dpkg-deb
sudo cpan IO::Compress::Lzma
把THEOS路径写进~/.bash_profile
末尾
THEOS=/opt/theos
PATH=${PATH}:${THEOS}/bin
官方默认是放在
~/theos
下,但安装过程发现,在这个目录下会报错
<built-in>:1:10: error: non-portable path to file
'"/UUsersGgkkttheosPPrefix.pch"'; specified path differs in case from file
name on disk [-Werror,-Wnonportable-include-path]
#include "/Users/GKK/theos/Prefix.pch"
^~~~~~~~~~~~~~~~~~~~~~~~~~~~~
"/UUsersGgkkttheosPPrefix.pch"
1 error generated.
make[3]: *** [/Users/GKK/Desktop/test/.theos/obj/debug/armv7/Tweak.xm.7a3a1d98.o] Error 1
make[2]: *** [/Users/GKK/Desktop/test/.theos/obj/debug/armv7/Test.dylib] Error 2
make[1]: *** [internal-library-all_] Error 2
make: *** [Test.all.tweak.variables] Error 2
下载Theos到$THEOS
位置,依次执行以下命令
git clone --recursive https://github.com/theos/theos.git $THEOS
sudo chown -R $(id -u):$(id -g) $THEOS
curl https://ghostbin.com/ghost.sh -o $THEOS/bin/ghost
chmod +x $THEOS/bin/ghost
通过nic.pl命令生成tweak项目
GKK:test $ nic.pl
NIC 2.0 - New Instance Creator
------------------------------
[1.] iphone/activator_event
[2.] iphone/application_modern
[3.] iphone/cydget
[4.] iphone/flipswitch_switch
[5.] iphone/framework
[6.] iphone/ios7_notification_center_widget
[7.] iphone/library
[8.] iphone/notification_center_widget
[9.] iphone/preference_bundle_modern
[10.] iphone/tool
[11.] iphone/tweak
[12.] iphone/xpc_service
Choose a Template (required):
输入11生成tweak模板
NIC 2.0 - New Instance Creator
------------------------------
[1.] iphone/activator_event
[2.] iphone/application_modern
[3.] iphone/cydget
[4.] iphone/flipswitch_switch
[5.] iphone/framework
[6.] iphone/ios7_notification_center_widget
[7.] iphone/library
[8.] iphone/notification_center_widget
[9.] iphone/preference_bundle_modern
[10.] iphone/tool
[11.] iphone/tweak
[12.] iphone/xpc_service
Choose a Template (required): 11
Project Name (required):
项目名字,我们这里输入Hello,接下来全部按回车即可。
NIC 2.0 - New Instance Creator
------------------------------
[1.] iphone/activator_event
[2.] iphone/application_modern
[3.] iphone/cydget
[4.] iphone/flipswitch_switch
[5.] iphone/framework
[6.] iphone/ios7_notification_center_widget
[7.] iphone/library
[8.] iphone/notification_center_widget
[9.] iphone/preference_bundle_modern
[10.] iphone/tool
[11.] iphone/tweak
[12.] iphone/xpc_service
Choose a Template (required): 11
Project Name (required): HelloTweak
Package Name [com.yourcompany.hellotweak]:
Author/Maintainer Name [GKK]:
[iphone/tweak] MobileSubstrate Bundle filter [com.apple.springboard]:
[iphone/tweak] List of applications to terminate upon installation (space-separated, '-' for none) [SpringBoard]:
Instantiating iphone/tweak in hellotweak/...
Adding 'HelloTweak' as an aggregate subproject in Theos makefile 'Makefile'.
Done.
在生成的hellotweak目录下的Tweak.xm写入
%hook SpringBoard
- (void)applicationDidFinishLaunching:(id)applicaton
{
%orig;
UIAlertView *alert = [[UIAlertView alloc] initWithTitle: @"Hello Tweak" message: nil delegate: self cancelButtonTitle:@"Cool" otherButtonTitles: nil];
[alert show];
[alert release];
}
%end
大概就是在SpringBoard这个系统APP上hookapplicationDidFinishLaunching
方法,也就是Method swizzling
,通过%orig
调用原先的方法。
安装SSH
通过设备上的Cydia安装OpenSSH
,在设置-WIFI中找到当前WIFI的IP地址。
在电脑上通过ssh root@这里填设备的IP
来连接,默认的密码是alpine
安装tweak
首先在Tweak项目目录下的Makefile中最上面加入THEOS_DEVICE_IP = 这里填设备的IP
最后一步,在命令行输入make package install
,在输入两次SSH root密码后,SpringBoard重启将看到Alter。
如果安装这里提示
substrate
相关报错,建议在Cydia
中重新安装下Substrate
就好了
首先定义资源文件,我们使用JSON做为配置的格式,大概如下:
{
"colors": {
"tint": "#404146",
"background": "#FFFFFF",
"text": "#404146",
"placeholder": "#AAAAAA",
"separator": "#C8C7CC",
"shadow_layer": "#00000026",
"tabBar_background": "#FFFFFF",
"tabBar_normal": "#8A8A8F",
"tabBar_selected": "#404146",
"navigationBar_background": "#FFFFFF",
"cell_background": "#FFFFFF",
"cell_selected_background": "#B8B8B8",
"switch_tint": "#3F72AF"
},
"images": {
"article_loading": "article_loading"
}
}
images 定义图片
大多数情况下,我们可以把纯色图标的Render AS 设置为 Template Image 来满足不同颜色的渲染,对于不是纯色图标才使用多张图片来定义。
首先通用的样式,比如主题色、字体色、背景色等,页面上NavigationBar、UILabel、UIButton等控件基本都固定使用了这些样式,那么这部分我们就可以自动更新。
而需要自定义的 属性样式
,我们通过扩展一系列key配置好属性样式名就行了,比如backgroundColorKey
、textColorKey
,而之后自动更新样式的过程就可以优先判断这些值是否不为空,否则就使用上面的通用样式。
extension UILabel {
/// 自动更新文本色的配置key
@IBInspectable var textColorKey: String? {
get {
return objc_getAssociatedObject(self, &ThemeUILabelTextColorKey) as? String
}
set {
objc_setAssociatedObject(self, &ThemeUILabelTextColorKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
}
}
}
负责切换主题,获取相应主题的资源,并自动更新控件通用样式或者自定义的属性样式
/// 当前主题
fileprivate(set) var style: ThemeStyle {
get {
if let currentStyleString = df.string(forKey: ThemeCurrentStyle),
let currentStyle = ThemeStyle(rawValue: currentStyleString) {
return currentStyle
}
return .default
}
set {
df.set(newValue.rawValue, forKey: ThemeCurrentStyle)
df.synchronize()
//加载主题资源
setup()
//通知现有页面更新
NotificationCenter.default.post(name: .ThemeStyleChange, object: nil)
}
}
/// 切换主题
func switchStyle() {
style = style == .default ? .night : .default
}
let style = self.style //当前样式
//从应用Bundle中拿相应主题名.theme文件
let path = Bundle.main.path(forResource: style.rawValue, ofType: "theme")!
let url = URL(fileURLWithPath: path)
let string = try! String(contentsOf: url)
let json = JSON(parseJSON: string)
self.colors = [:]
self.images = [:]
//颜色
let colorsJSON = json["colors"].dictionaryValue
colorsJSON.forEach { (key, value) in
self.colors[key] = UIColor(value.stringValue)
}
//图片
let imagesJSON = json["images"].dictionaryValue
imagesJSON.forEach { (key, value) in
self.images[key] = value.stringValue
}
/// 自动更新到当前主题下的通用样式
///
/// - Parameter view: View
func updateThemeSubviews(with view: UIView) {
guard view.autoUpdateTheme else { //不需要自动切换样式
//更新subviews
//UIButton中有UILabel,所以不需要更新subviews
guard !(view is UIButton) else {
return
}
view.subviews.forEach { (subView) in
updateThemeSubviews(with: subView)
}
return
}
//各种视图更新
if let tableView = view as? UITableView {
//取消当前选择行
if let selectedRow = tableView.indexPathForSelectedRow {
tableView.deselectRow(at: selectedRow, animated: false)
}
tableView.backgroundColor = Theme.backgroundColor
tableView.separatorColor = Theme.separatorColor
}
else if let cell = view as? UITableViewCell {
cell.backgroundColor = Theme.cellBackgroundColor
cell.contentView.backgroundColor = cell.backgroundColor
cell.selectedBackgroundView?.backgroundColor = Theme.cellSelectedBackgroundColor
}
else if let collectionView = view as? UICollectionView {
collectionView.backgroundColor = C.theme.backgroundColor
}
else if let cell = view as? UICollectionViewCell {
cell.backgroundColor = Theme.cellBackgroundColor
cell.selectedBackgroundView?.backgroundColor = Theme.cellSelectedBackgroundColor
}
else if let lab = view as? UILabel {
if let key = lab.textColorKey {
lab.textColor = self.color(forKey: key)
} else {
lab.textColor = Theme.textColor
}
}
else if let btn = view as? UIButton {
if let key = btn.titleColorKey {
btn.setTitleColor(self.color(forKey: key), for: .normal)
} else {
btn.setTitleColor(Theme.textColor, for: .normal)
}
if let key = btn.selectedColorKey {
btn.setTitleColor(self.color(forKey: key), for: .selected)
}
}
else if let textField = view as? UITextField {
if let key = textField.textColorKey {
textField.textColor = self.color(forKey: key)
} else {
textField.textColor = Theme.textColor
}
if let key = textField.placeholderColorKey {
textField.placeholderColor = self.color(forKey: key)
}
}
else if let textView = view as? UITextView {
if let key = textView.textColorKey {
textView.textColor = self.color(forKey: key)
} else {
textView.textColor = Theme.textColor
}
//UITextView不能通过appearance设置keyboardAppearance,所以在此处设置
let keyboardAppearance: UIKeyboardAppearance = self.style == .default ? .default : .dark
textView.keyboardAppearance = keyboardAppearance
}
else if let imageView = view as? UIImageView {
if let key = imageView.imageNamedKey {
imageView.image = self.image(forKey: key)
}
}
else if let switchView = view as? UISwitch {
switchView.onTintColor = Theme.switchTintColor
}
else if let datePicker = view as? UIDatePicker {
datePicker.setValue(Theme.textColor, forKey: "textColor")
datePicker.setValue(false, forKey: "highlightsToday")
}
//主题色
if let key = view.tintColorKey {
view.tintColor = self.color(forKey: key)
}
//背景色
if let key = view.backgroundColorKey {
view.backgroundColor = self.color(forKey: key)
}
//更新subviews
//UIButton中有UILabel,所以不需要更新subviews
guard !(view is UIButton) else {
return
}
view.subviews.forEach { (subView) in
updateThemeSubviews(with: subView)
}
}
其中Theme.xxxColor
是扩展的getter属性,用于访问当前样式某个颜色值,建议自定义的颜色与图片也基于Theme扩展。
由于自动更新过程就是对view递归设置,而该方法需要手动调用,调用时机一般是在viewDidLoad中或者收到
ThemeStyleChange
通知时。对于UITableView与UICollectionView中,通常会在cell的awakeFromNib中调用一次。
切换样式后会通知ThemeStyleChange
,我们在各种BaseXXX
中调用updateThemeSubviews
。
使用
BaseXXX
基类的方式确实不优雅,在意的读者可以看下 DKNightVersion 代码,它是基于NSObject扩展的,对业务代码耦合低,但遗憾没有自动更新通用样式
功能。
class BaseVC: UIViewController {
deinit {
NotificationCenter.default.removeObserver(self)
}
override func viewDidLoad() {
super.viewDidLoad()
updateTheme()
//监听主题改变通知
NotificationCenter.default.addObserver(self, selector: #selector(self.onThemeChange), name: .ThemeStyleChange, object: nil)
}
@objc func onThemeChange() {
UIView.animate(withDuration: 0.25) {
self.updateTheme()
}
}
/// 更新当前ViewController的主题
func updateTheme() {
if view.backgroundColorKey == nil {
view.backgroundColor = Theme.backgroundColor //顶层View
}
Theme.shared.updateThemeSubviews(with: view)
}
}
其它BaseXXX直接套用以上的代码,放在updateTheme中就行了
BaseTabBarController
tabBar.tintColor = Theme.tabBarSelectedColor
tabBar.barTintColor = Theme.tabBarBackgroundColor
tabBar.backgroundColor = Theme.tabBarBackgroundColor
tabBar.isTranslucent = false
if #available(iOS 10.0, *) {
tabBar.unselectedItemTintColor = Theme.tabBarNormalColor
} else {
UIView.performWithoutAnimation {
self.viewControllers?.forEach({ (vc) in
vc.tabBarItem.setTitleTextAttributes([NSAttributedStringKey.foregroundColor: Theme.tabBarNormalColor],
for: .normal)
vc.tabBarItem.setTitleTextAttributes([NSAttributedStringKey.foregroundColor: Theme.tabBarSelectedColor],
for: .selected)
})
}
}
BaseNavigationController
//背景
let bgImageSize = CGSize(width: view.frame.width, height: 64)
UIGraphicsBeginImageContext(bgImageSize)
Theme.navigationBarBackgroundColor.setFill()
UIGraphicsGetCurrentContext()!.fill(CGRect(origin: CGPoint(), size: bgImageSize))
let bgImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
navigationBar.setBackgroundImage(bgImage, for: .default)
navigationBar.backgroundColor = Theme.navigationBarBackgroundColor
navigationBar.barTintColor = Theme.textColor
navigationBar.tintColor = Theme.textColor
navigationBar.titleTextAttributes = [NSAttributedStringKey.foregroundColor: Theme.textColor]
UIBarButtonItem.appearance().tintColor = Theme.textColor
//已打开的页面使用appearance无效
viewControllers.forEach { (vc) in
vc.navigationItem.backBarButtonItem?.tintColor = Theme.textColor
vc.navigationItem.leftBarButtonItems?.forEach({ (item) in
item.tintColor = Theme.textColor
})
vc.navigationItem.rightBarButtonItems?.forEach({ (item) in
item.tintColor = Theme.textColor
})
}
BaseXXXCell
class BaseTableViewCell: UITableViewCell {
override func awakeFromNib() {
super.awakeFromNib()
if selectionStyle != .none {
selectedBackgroundView = UIView(frame: frame)
}
Theme.shared.updateThemeSubviews(with: self)
}
}
这里没有监听ThemeStyleChange
通知是因为自动更新的过程会更新到TableView下所有可见的UITableViewCell,当然不可见的UITableViewCell也需要更新,我们可以用以下代码手动更新
if let dataSource = tableView.dataSource {
let sectionNumber = dataSource.numberOfSections?(in: tableView) ?? tableView.numberOfSections
for section in 0..<sectionNumber {
for row in 0..<dataSource.tableView(tableView, numberOfRowsInSection: section) {
let cell = dataSource.tableView(tableView, cellForRowAt: IndexPath(row: row, section: section))
Theme.shared.updateThemeSubviews(with: cell)
}
}
}
Cell的Selection不可以设置颜色,我们通过自定义selectedBackgroundView
来实现,在自动更新的过程中设置cell.selectedBackgroundView.backgroundColor
。
另外如果TableView处于选中状态,选中行的selectedBackgroundView会为nil,我们在设置前先deselectRow
。
由于css样式优先级的机制,最新的样式可覆盖旧的样式,所以我们只需要为每种样式添加一种夜间模式样式就行。
/*夜间模式样式*/
.night-mode {
background-color: #333333;
}
.night-mode #articleCon p,
.night-mode #articleCon ol li,
.night-mode #articleCon ul li {
color: #CDCDCD;
}
在原生端切换样式时,通过JS函数把夜间模式的css附加上去就行了,切换回默认主题删除样式即可。
//JS代码
//切换至夜间模式
Enclave.switchToNightMode = function() {
document.querySelector('html').classList.add('night-mode')
}
//切换至白天模式
Enclave.switchToLightMode = function() {
document.querySelector('html').classList.remove('night-mode')
}
UIApplication.shared.statusBarStyle
设置
iOS默认不可以通过UIApplication.shared.statusBarStyle
设置样式,需要info.plist中把UIViewControllerBasedStatusBarAppearance
设置为false
设置UIPickerView文字颜色
func pickerView(_ pickerView: UIPickerView, attributedTitleForRow row: Int, forComponent component: Int) -> NSAttributedString? {
let string = self.dataSource[row]
return NSAttributedString(string: string, attributes: [NSForegroundColorAttributeName: C.theme.textColor])
}
datePicker.setValue(C.theme.textColor, forKey: "textColor")
datePicker.setValue(false, forKey: "highlightsToday") //取消datePicker.date当前日期高亮
UIKeyboardAppearance.dark
let keyboardAppearance: UIKeyboardAppearance = style == .default ? .default : .dark
UITextField.appearance().keyboardAppearance = keyboardAppearance
但以上代码应用在UITextView会Crash,暂不知道什么原因造成的,有同学知道可以告诉下。
所以对于UITextView的keyboardAppearance我们需要通过实例设置
let keyboardAppearance: UIKeyboardAppearance = style == .default ? .default : .dark
textView.keyboardAppearance = keyboardAppearance
文中有何错误还望指教~
]]>轻阅读
产品,在技术选型时内容的载体采用了HTML,这样内容可以适用于全平台显示。
轻阅读是从技术角度分析的,因为没有像微信读书这类应用有长篇文字的书籍,需要实现各种PDF和ePub格式解析以及排版,我们只需要用UIWebView即可解决。
首先内容
是body
中的一段HTML,通过接口拿到文章的内容
后替换到完整的HTML模板中
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" >
{css}
</head>
<body id='articleCon'>
{body}
</body>
</html>
{css}
是内容的样式,如标题、段落、脚注等,articleCon
是为了样式选择器。
css来源有二种情况,启动时加载服务器最新的,如果失败则使用本地的备份css
最后使用loadHTMLString
来加载替换后的HTML。
飞地有几个内容模块都是基于HTML来做为内容的载体,但页面一般不只是纯内容,会有一些其它元数据,这些使用原生视图显示。
如上图文章详情页,整个页面的容器是UITableView
,封面图、作者、日期、内容WebView都是tableHeaderView,评论列表为Cell。
tableHeaderView的高度我们需要自己计算,而WebView的高度可以在webViewDidFinishLoad后获取,并重新设置tableHeaderView的高度。
//原生代码
var contentHeight = webView.scrollView.contentSize.height
let fitHeight = webView.sizeThatFits(CGSize(width: 1.0, height: 1.0)).height
if fitHeight > contentHeight {
contentHeight = fitHeight
}
if let documentHeight = jsBridge.getContentHeight(),
documentHeight > contentHeight {
contentHeight = documentHeight
}
jsBridge.getContentHeight()
是执行JS层的代码document.body.scrollHeight * window.scale
获取高度
Tip:直接设置
tableView.tableHeaderView.frame.height
时可能不会生效,需要重新tableView.tableHeaderView = tableHeaderView
渲染一次。
文章有各种各样的样式,移动设备碎片化,使用px明显已经不满足需求了,所以我们使用rem。
//JS代码
window.scale = 1.0; //标志当前viewport使用的scale
!function(e){function t(a){if(i[a])return i[a].exports;var n=i[a]={exports:{},id:a,loaded:!1};return e[a].call(n.exports,n,n.exports,t),n.loaded=!0,n.exports}var i={};return t.m=e,t.c=i,t.p="",t(0)}([function(e,t){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var i=window;t["default"]=i.flex=function(normal,e,t){var a=e||100,n=t||1,r=i.document,o=navigator.userAgent,d=o.match(/Android[\S\s]+AppleWebkit\/(\d{3})/i),l=o.match(/U3\/((\d+|\.){5,})/i),c=l&&parseInt(l[1].split(".").join(""),10)>=80,p=navigator.appVersion.match(/(iphone|ipad|ipod)/gi),s=i.devicePixelRatio||1;p||d&&d[1]>534||c||(s=1);var u=normal?1:1/s,m=r.querySelector('meta[name="viewport"]');m||(m=r.createElement("meta"),m.setAttribute("name","viewport"),r.head.appendChild(m)),m.setAttribute("content","width=device-width,user-scalable=no,initial-scale="+u+",maximum-scale="+u+",minimum-scale="+u),i.scale=u,r.documentElement.style.fontSize=normal?"50px": a/2*s*n+"px"},e.exports=t["default"]}]); flex(false,100, 1);
html {
font-size: 62.5%;
}
上述代码分别加在文章内容HTML模板中与文章css中,而飞地的设计图输出是375pt * 667pt
,所以我们只需要把设计上的pt/50
转换成rem就行了(50是设备缩放基准值
),如设计图上的正文字体是17pt
,那么对应css的rem应该是 17pt /50 = 0.34rem
#articleCon n p {
font-size: 0.34rem;
}
由于有离线阅读需求,app启动时会提前缓存文章,其实也就是存储文章的封面图、内容HTML等,但html中也有图片,所以我们需要用正则
拿到所有img.src
,然后缓存在本地,并将文章标识为已缓存。
<img\\s[\\s\\S]*?src\\s*?=\\s*?['\"](.*?)['\"][\\s\\S]*?>*
前期我们采用的方式是将所有img.src
保持相对路径,loadHTMLString
时如果文章标识已缓存则baseURL使用本地Path,否则使用线上URL。
优化后统一换成URLProtocol
处理,提前缓存文章时用第三方图片加载库
下载好图片,等阅读文章时利用URLProtocol
机制拦截,如果是WebView的图片,判断该图片是否缓存在第三方图片加载库
中,否则手动加载图片Data并且保存在第三方图片加载库
,下次再拦截到此图片的请求直接从第三方图片加载库
缓存中取。
URLProtocol
是全局拦截,判断请求是否为WebView的图片可在shouldStartLoadWith
时附加自定义Header,在URLProtocol
识别Header就行
有二种方式,原生提供的JavaScriptCore、JS层通过iFrame加载URI(URI包括scheme与参数)原生在shouldStartLoadWith
中拦截,飞地使用了第一种。
//原生代码
/// 原生JavaScriptCore暴露给JS层的对象
@objc protocol ContentWebViewJavaScriptBridgeProtocol: JSExport {
/// 图片点击回调
func onImageClick(_ currentImageIndex: Int, _ images: [String])
}
/// 原生与JS桥接
class ContentWebViewJavaScriptBridge: NSObject, ContentWebViewJavaScriptBridgeProtocol {
//原生暴露给JS层的对象名
static let name = "EnclaveNative"
fileprivate var jsContext: JSContext?
fileprivate weak var webView: UIWebView?
var imageClickCallback: ((_ currentImageIndex: Int, _ images: [String])->())?
convenience init(webView: UIWebView) {
self.init()
guard let jsContext = webView.value(forKeyPath: "documentView.webView.mainFrame.javaScriptContext") as? JSContext else { return }
self.jsContext = jsContext
self.webView = webView
jsContext.setObject(self, forKeyedSubscript: ContentWebViewJavaScriptBridge.name as NSCopying & NSObjectProtocol)
jsContext.exceptionHandler = { (ctx, value) in
L.debug(value?.description ?? "exception")
}
}
func onImageClick(_ currentImageIndex: Int, _ images: [String]) {
//回调在UI线程
DispatchQueue.main.async {
self.imageClickCallback?(currentImageIndex, images)
}
}
}
//MARK: - Public
extension ContentWebViewJavaScriptBridge {
/// 获取html中所有图片地址
func getImages() -> [String]? {
guard let jsContext = jsContext else { return nil }
guard let jsValue = jsContext.evaluateScript("getImageSrcs()") else { return nil }
return jsValue.toArray() as? [String]
}
/// 获取内容高度
func getContentHeight() -> CGFloat? {
if let heightString = webView?.stringByEvaluatingJavaScript(from: "Enclave.getContentHeight()"),
let height = Float(heightString) {
return CGFloat(height)
}
return nil
}
/// 切换主题
func switchTheme() {
if ELThemeManager.shared.style == .night {
switchToNightMode()
} else {
switchToLightMode()
}
}
/// 切换至夜间模式
fileprivate func switchToNightMode() {
webView?.stringByEvaluatingJavaScript(from: "Enclave.switchToNightMode()")
}
/// 切换至日间模式
fileprivate func switchToLightMode() {
webView?.stringByEvaluatingJavaScript(from: "Enclave.switchToLightMode()")
}
}
EnclaveNative.onImageClick(currentImageIndex, srcs)
webView.stringByEvaluatingJavaScript(from: "xxx()")
点击内容HTML中的图片,需要在原生端显示查看。
首先在DOM加载完毕后为所有的有效img注册click事件,在事件触发时拿到所有img.src与当前img的index传到原生端并显示。
//JS代码
function getImageSrcs() {
var srcs = []
var imgs = document.getElementsByTagName('img')
for (var i = 0; i < imgs.length; i++) {
if(imgs[i].src.indexOf('data:') == 0 || imgs[i].parentNode.nodeName.toLocaleLowerCase() == 'a') {
continue
}
srcs.push(imgs[i].src)
}
return srcs
}
function onImageClick(currentImageIndex) {
var srcs = getImageSrcs()
//原生回调
EnclaveNative.onImageClick(currentImageIndex, srcs)
}
function didload() {
var imgs = document.getElementsByTagName('img')
//有效图片index,因为可能会存在可跳转的图片
var index = 0
for (var i = 0; i < imgs.length; i++) {
//加载失败时默认图,且不可点击
if(imgs[i].naturalWidth == "undefined" || imgs[i].naturalWidth == 0) {
imgs[i].src = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAYEBAQFBAYFBQYJBgUGCQsIBgYICwwKCgsKCgwQDAwMDAwMEAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwBBwcHDQwNGBAQGBQODg4UFA4ODg4UEQwMDAwMEREMDAwMDAwRDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDP/AABEIADIAZAMBEQACEQEDEQH/xABLAAEBAAAAAAAAAAAAAAAAAAAACBABAAAAAAAAAAAAAAAAAAAAAAEBAAAAAAAAAAAAAAAAAAAAABEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AoQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH//Z'
}
//并图片本身包含链接时也不可点击
if(imgs[i].parentNode.nodeName.toLocaleLowerCase() == 'a') {
continue
}
imgs[i].imageIndex = index++ //给img元素设置一个index
imgs[i].onclick = function(e) {
onImageClick(e.target.imageIndex) //拿当前事件的元素index然后回调
}
}
}
window.addEventListener('load', function() {
didload()
}, false)
关于原生iOS端实现夜间模式可查看这里,这里主要讲述web页面实现。
由于css样式优先级的机制,最新的样式可覆盖旧的样式,所以我们只需要为每种样式添加一种夜间模式样式就行。
/*夜间模式样式*/
.night-mode {
background-color: #333333;
}
.night-mode #articleCon p,
.night-mode #articleCon ol li,
.night-mode #articleCon ul li {
color: #CDCDCD;
}
在原生端切换样式时,通过JS函数把夜间模式的css附加上去就行了,切换回默认主题删除样式即可。
//JS代码
//切换至夜间模式
Enclave.switchToNightMode = function() {
document.querySelector('html').classList.add('night-mode')
}
//切换至白天模式
Enclave.switchToLightMode = function() {
document.querySelector('html').classList.remove('night-mode')
}
有何错误还望指教,谢谢~
]]>设置 -> 显示器 -> 缩放 -> 旋转90度
(注意,我操作的是Air的桌面显示器设置,应该操作外接显示器中显示的窗口),之后外接显示屏没有旋转,Air的桌面旋转了,于是就悲剧了,退出设置后重新点显示器,显示 未能载入偏好设置面板 显示器
错误,此时Air屏幕也是旋转的,如下。。。。
]]>Demo同步更新到Swift2.3
3D Touch最先应用在Apple Watch上面,但叫Force Touch
,后在iPhone6s上加入了此特性,并改名3D Touch。值得注意的是目前3D Touch只支持iPhone6S以后的机型,包括现有Xcode7中6s的模拟器也不支持,不过Github上的SBShortcutMenuSimulator项目通过Hack方式已经实现了Quick Actions
快捷访问,但不能使用Peek&Pop
快速预览。
如果你还不知道3D Touch是什么,可以看看官方宣传视频
系统环境: iOS9 or later
开发环境: Swift2.3 & Xcode7.3.1
Demo: 3DTouchDemo
效果:
3D Touch可以分为三种:
配置Actions可以通过工程Info.plist
文件静态配置,也可以在运行时动态添加,两者可以一起使用。
静态配置在Info.plist
中UIApplicationShortcutItems
节点数组下添加相应Actions Item信息
<key>UIApplicationShortcutItems</key>
<array>
<dict>
<key>UIApplicationShortcutItemIconType</key>
<string>UIApplicationShortcutIconTypeSearch</string>
<key>UIApplicationShortcutItemTitle</key>
<string>搜索</string>
<key>UIApplicationShortcutItemType</key>
<string>me.mokai.TouchDemo.action.search</string>
</dict>
...
</array>
动态配置通过UIApplication的shortcutItems
添加,shortcutItems是一个UIApplicationShortcutItem
数组
let type = "me.mokai.TouchDemo.action.identify"
let title = "听歌识别"
let shortcutItem = UIApplicationShortcutItem(type: type, localizedTitle: title,
localizedSubtitle: nil, icon: UIApplicationShortcutIcon(templateImageName: "quick_filter"), userInfo: nil)
application.shortcutItems = [shortcutItem]
Note
type
是必须的,它代表着我们从桌面点击Actions进入到应用调用application(application, performActionForShortcutItem:, completionHandler:)
时的唯一标识,另外userInfo可以附加每个actions的数据,如最近听歌的歌曲id好了,下面介绍本文重头戏,先上效果
Peek窗口的内容其实是目标VC【ps即将要显示的ViewController】的一个实时快照,但它不可以点击。Peek触发阶段有三种:
Peek由一个可响应事件的View
触发,默认是关闭的,我们需要通过控制器的registerForPreviewingWithDelegate: sourceView:
方法注册,第一个参数为UIViewControllerPreviewingDelegate
的代理,Peek触发轻压时会调用其previewingContext:viewControllerForLocation
方法,重压时会调用previewingContext:commitViewController:
方法。第二个参数为触发Peek事件的源视图
//注册
registerForPreviewingWithDelegate(self, sourceView: userVCBtn)
//Delegate
//轻压,进入第二阶段,显示Peek窗口
func previewingContext(previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController? {
let userVc = self.storyboard?.instantiateViewControllerWithIdentifier("UserViewController") as! UserViewController
return userVc;
}
//重压,进入第三阶段,显示真正的ViewController
func previewingContext(previewingContext: UIViewControllerPreviewing, commitViewController viewControllerToCommit: UIViewController) {
showViewController(viewControllerToCommit, sender: self)
}
如果Peek窗口需要Quick Actions菜单,在目标VC中重写previewActionItems
方法返回一个UIPreviewActionItem
或者一个UIPreviewActionGroup
数组就行了。
//目标VC
lazy var previewActions: [UIPreviewActionItem] = {
func previewActionForTitle(title: String, style: UIPreviewActionStyle = .Default) -> UIPreviewAction {
return UIPreviewAction(title: title, style: style) { previewAction, viewController in
print("点击了\(title)") //这里是Actions响应
}
}
let action1 = previewActionForTitle("关注TA",style: .Destructive) //显示红色,代表重要Action
let action2 = previewActionForTitle("私信TA")
//子Actioons
let subAction1 = previewActionForTitle("微博")
let subAction2 = previewActionForTitle("好友圈")
let subAction3 = previewActionForTitle("QQ")
let subAction4 = previewActionForTitle("微信")
let groupedActions = UIPreviewActionGroup(title: "分享…", style: .Default, actions: [subAction1, subAction2, subAction3, subAction4] )
return [action1, action2, groupedActions]
}()
override func previewActionItems() -> [UIPreviewActionItem] {
return previewActions
}
上面是代码激活Peek的方式,还有更Peek的方式:直接在Storyboard中使用Segue,在Segue属性面板中把Peek & Pop 勾选上就完事了。
使用这种方式指定我们在代码中连注册都不用,所以使用SB的项目适配3D Touch那是分分钟搞定的事,尤其在Xcode7出了Storyboard References
后,我大 Swift + Storyboard
组合势必统一iOS界~
好了,有点小激动了,继续回到正文
在正常情况下,Peek窗口默认显示目标VC的整个View,但在实际应用中,可能会有更多的需求,比如说二个Button Push的是同一个VC,但是需要分别显示不同的Peek窗口。
其实也很简单,我们只需要自定义一个Peek的生命周期扩展就行了,previewingContext:viewControllerForLocation:
方法中代表Peek的开始,previewingContext:commitViewController
代表Peek的结束,然后在目标VC中重写二个方法就行了
//UIViewController+PeekCycle.swift
/**
Peek生命周期
**/
extension UIViewController {
//开始peek,VC为Peek显示做初始化
func beginPeek(){}
//结束peek,VC为真正显示做初始化
func endPeek(){}
}
//Delegate
func previewingContext(previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController? {
let detailVc = self.storyboard?.instantiateViewControllerWithIdentifier("DetailViewController") as! DetailViewController
//指定Peek窗口类型
detailVc.peekType = .Image
//设置Peek的高度
detailVc.preferredContentSize = CGSize(width: 0.0, height: 320);
detailVc.view //先访问一下view,初始化
detailVc.beginPeek() //peek开始
return detailVc;
}
func previewingContext(previewingContext: UIViewControllerPreviewing, commitViewController viewControllerToCommit: UIViewController) {
viewControllerToCommit.endPeek() //peek结束
showViewController(viewControllerToCommit, sender: self)
}
//目标VC
override func beginPeek() {
if(peekType == .Comments){ //如果是评论则只显示评论视图
imageView.hidden = true
}else{ //否则显示图片
commentsView.hidden = true
}
}
override func endPeek() {
if(peekType == .Comments){
imageView.hidden = false
}else{
commentsView.hidden = false
}
}
Note
preferredContentSize
registerForPreviewingWithDelegate
注册VC的self.view,虽然可以自动注册subviews,但是如果说你的VC中不止一种视图要触发Peek,那么它会分分钟教你做人的道理。高级玩法,绘图、游戏,把3D Touch发挥到极致。不过我也唔知玩也暂时没这方面需求,有需求看官方绘图demo
本人目前是一名自由职业者,接受移动两端的项目开发,如果你有需求或者有资源请速与我联系吧,QQ865425695
]]>UIApplication.sharedApplication().statusBarStyle = .LightContent
TARGETS
中设置,发现都无效。
我们只需要把Info.plist
中的View controller-based status bar appearance
改为NO就行了,默认为YES
postNotificationName触发后的监听代码是同步还是异步执行的?
,我当时知道触发后的监听代码和触发的代码是在同一线程上执行的
,但却回答了是异步,哎,我这逻辑又下降了。。。。
举个栗子,比如说HTTP异步请求返回代码中我们触发一个通知,这个时候在监听的代码中直接去设置视图就会报错,因为当前线程不是主线程,正确的姿态应该是
dispatch_async(dispatch_get_main_queue(), { () -> Void in
NSNotificationCenter.defaultCenter().postNotificationName(kNotificationName, object: nil)
})
当然你也可以选择另一种方式,NSNotificationQueue
通知队列,我们可以通过它做很多基于Runloop的需求,最重要的是它是异步执行的。
let queue = NSNotificationQueue.defaultQueue()
queue.enqueueNotification(NSNotification(name: kNotificationName, object: nil), postingStyle: NSPostingStyle.PostNow)
比如说调用系统的MPMovieViewController
播放视频时,我们会在AppDelegate中或者UIViewController中更新允许横屏的配置,当播放器旋转到横屏(此时设备应该也是处于横屏),APP现有ViewController
也会跟着旋转,那么问题来了,当再次回到APP时,此时现有ViewController
是处于横屏的,我们再去代理中更新为竖屏会发现代理根本不会被调用,这时我们就需要用到下面这段代码,去手动触发屏幕旋转事件
let vc = UIViewController();
self.presentViewController(vc, animated: false, completion: nil)
vc.dismissViewControllerAnimated(false, completion: nil)
self.modalPresentationStyle = .CurrentContext
就可以实现效果,如以下半透明的代码
let vc = UIViewController()
vc.view.backgroundColor = UIColor(red: 0.000, green: 0.000, blue: 0.000, alpha: 0.5)
self.modalPresentationStyle = .CurrentContext
self.presentViewController(vc, animated: true, completion: nil)
但是你会发现这段代码在iOS8、iOS9上面运行依旧一片黑,因此iOS8以后得这么干
let vc = UIViewController()
vc.view.backgroundColor = UIColor(red: 0.000 , green: 0.000 , blue: 0.000, alpha: 0.5)
if let version = Float(UIDevice.currentDevice().systemVersion) where version >= 8 {
vc.modalPresentationStyle = .OverCurrentContext //注意此处是弹出VC对象,不是self
} else {
self.modalPresentationStyle = .CurrentContext
}
self.presentViewController(vc, animated: true, completion: nil)
iOS8后增加了OverCurrentContext
取代CurrentContext
,并且设置的对象是弹出的VC
Demo同步更新到Swift2.3
在真正将国际化实践前,只知道通过NSLocalizedString
方法将相应语言的字符串加载进来即可。但项目的新需求增加英文版本,并支持应用内无死角切换~,这才跳过各种坑实现了应用内切换语言,并记录至此。
系统环境: iOS7 or later
开发环境: Swift2.3 & Xcode7.3.1
DEMO: LocalDemo
这个Demo的功能主要是切换语言后相应的界面文字&图片以及搜索引擎都会随语言变化。我们会围绕这个DEMO进行讲解,读者可以先下载这个Demo运行看下效果再往下
国际化其实都大同小异,其核心思想就是为每种语言单独定义一份资源
。
iOS就是通过xxx.lproj
目录来定义每个语言的资源,这里的资源可以是图片,文本,Storyboard,Xib等。我们可以看看LocalDemo源代码的物理目录结构
Base,暂时无需理会
English
中文
每种语言都有自己的 语言代码.lproj文件夹,加载资源时只需要加载相应语言文件夹下的资源就OK,这步可以系统为我们完成,也可以手动去做。
项目源代码中如果有多个不同目录的国际化资源,则会有产生多个xxx.lproj,但在编译打包后,会集中放在app的根目录中的xxx.lproj中,不信你看~
首先点击项目->PROJECT->Info->Localizations中添加要支持的语言
此处Use Base Internationalization开启状态下,每个国际化资源文件会有个Base选项,主要针对String,Storyboard,Xib作为一个基础的模板,像后述storyboard国际化中方案二就是基于Base StoryBoard进行改动。
在点击+
添加相应语言时会弹出以下对话框,意思是为现有的资源添加语言文件,我们点击Finish
就行了
主要针对代码中的字符串进行国际化,比如说一些消息,UI标题等。
我们通过一个Localizable.strings
文件来存储每个语言的文本,它是iOS默认加载的文件,如果想用自定义名称命名,在使用NSLocalizedString
方法时指定tableName为自定义名称就好了,但你的应用规模不是很大就不要分模块搞特殊了。
每个资源文件如果想为一种语言添加支持,通过其属性面板中的Localization
添加相应语言就行了,此时Localizable.strings
处于可展开状态,子级有着相应语言的副本。我们把相应语言的文本放在副本里面就行了
此处Base与前面提过到的
开启Use Base Internationalization
是有关联的,只有开启了全局Use Base Internationalization
此处才会显示。那为什么这里没有勾选Base?Base做为一个基础模板,作用于Strings文件是没有太大意义的,另外去掉Base意义着在Base.lproj中少了一个strings文件,APP大小也所有下降,这点对于图片的Base更是如此
在上图可以看到其实就是为每一套语言新建一份strings,其内容采用"key" = "value";
的格式,注意有;
号
我们在代码中这样写就行了
NSLocalizedString("首页",comment: "")
NSLocalizedString("好友",comment: "")
NSLocalizedString("我",comment: "")
另外中文strings【Localizable.strings(Simplified)】可以不要的(可以理解为中文为APP的默认语言),因为key就是value,当找不到相应的语言strings或value时会直接返回key。nice!这样一来我们做文本的国际化就只要维护一个英文副本strings就O了
二种方案,通过原生支持与自定义命名
注意,新版Xcode中Images.xcassets不支持国际化(属性页面中没有
Localization
),Xcode5以前是支持的
方案一:自定义文本命名
利用文本国际化的方式,在代码中调用
UIImage(named: NSLocalizedString("search_logo",comment: ""))
不推荐,一是因为做法太low了,工作量明显加大。二是不能在Storyboard或XIB中使用
方案二:原生支持
同上,Base副本去掉。另外需要注意的是,使用这种方式,在XIB或Storyboard中引用图片时如果只使用名称是实时显示不了的,一定要加上后缀名。如avater.png
使用方式不变,iOS会自动找相应语言(xxx.lproj)下的图片
UIImage(named: "avater")
对于图片的放置,正确姿态应该是需要国际化的图片放在自定义Group里面,不需要国际化的图片放在Images.xcassets
前面的两种资源国际化比较简单,但Storyboard国际化就稍微麻烦了点。同样它也有二种方案
方案一:每种语言定制一套Storyboard
在上图我们可以看到,每种语言都可以切换为strings或Storyboard(默认为strings)。如果选用Interface Builder Storyboard
方案,那么每种语言都有一套相应的Storyboard,各个语言Storyboard间的界面改动不关联
方案二:基于基础的Base StoryBoard
以及每种语言一套strings
基于一个基础的Storyboard,可以看作是一个基础的模板,Storyboard里面所有的文本类资源(如UILabel的text)都会被放在相应语言的strings里面。此时我们为Storyboard里的字符类资源作国际化只需要编辑相应语言的strings就行了
首选方案二。因为采用方案一,意义着你每改动一个界面元素就得去相应语言Storyboard一一改动,那跟为每个语言新起一个项目是一样的道理。但是采用方案二,我们只需改动Base Storyboard就行了
注意,方案二中相应语言的strings一旦生成后,Base Storyboard有任何编辑都不会影响到strings,这就意味着如果我们删除或添加了一个UILabel的text,strings也不能同步改动
还好,Xcode为我们提供了ibtool
工具来生成Storyboard的strings文件。
ibtool Main.storyboard --generate-strings-file ./NewTemp.string
但是ibtool生成的strings文件是BaseStoryboard的strings(默认语言的strings),且会把我们原来的strings替换掉。所以我们要做的就是把新生成的strings与旧的strings进行冲突处理(新的附加上,删除掉的注释掉),这一切可以用这个pythoy脚本来实现,见AutoGenStrings.py。然后我们将借助Xcode 中 Run Script
来运行这段脚本。这样每次Build时都会保证语言strings与Base Storyboard保持一致
应用启动时,首先会读取NSUserDefaults中的key为AppleLanguages
的内容,该key返回一个String数组,存储着APP支持的语言列表,数组的第一项为APP当前默认的语言。
在安装后第一次打开APP时,会自动初始化该key为当前系统的语言编码,如简体中文就是zh-Hans。
//获取APP当前语言
(NSUserDefaults.standardUserDefaults().valueForKey("AppleLanguages") as! Array<String>)[0]
那么我们要实现语言切换改变AppleLanguages
的值即可,但是这里有一个坑,因为苹果没提供给我们直接修改APP默认语言的API,我们只能通过NSUserDefaults手动去操作,且AppleLanguages
的值改变后APP得重新启动后才会生效(才会读取相应语言的lproj中的资源,意义着就算你改了,资源还是加载的APP启动时lproj中的资源),猜测应该是框架层在第一次加载时对AppleLanguages
的值进行了内存缓冲
//设置APP当前语言
var def = NSUserDefaults.standardUserDefaults()
def.setValue([“zh-Hans”], forKey:"AppleLanguages")
def.synchronize()
那么问题来了,如何做到改变AppleLanguages
的值就加载相应语言的lproj资源?
其实,APP中的资源加载(Storyboard、图片、字符串)都是在NSBundle.mainBundle()
上操作的,那么我们只要在语言切换后把NSBundle.mainBundle()
替换成当前语言的bundle就行了,这样系统通过NSBundle.mainBundle()
去加载资源时实则是加载的当前语言bundle中的资源
lproj目录可以用一个NSBundle表示
import Foundation
/**
* 当调用onLanguage后替换掉mainBundle为当前语言的bundle
*/
private let _bundle:UnsafePointer<Void> = unsafeBitCast(0,UnsafePointer<Void>.self)
class BundleEx: NSBundle {
override func localizedStringForKey(key: String, value: String?, table tableName: String?) -> String {
if let bundle = languageBundle() {
return bundle.localizedStringForKey(key, value: value, table: tableName)
}else{
return super.localizedStringForKey(key, value: value, table: tableName)
}
}
}
extension NSBundle {
private struct Static {
static var onceToken : dispatch_once_t = 0
}
func onLanguage(){
//替换NSBundle.mainBundle()为自定义的BundleEx
dispatch_once(&Static.onceToken) {
object_setClass(NSBundle.mainBundle(), BundleEx.self)
}
}
//当前语言的bundle
func languageBundle()->NSBundle?{
return Languager.standardLanguager().currentLanguageBundle
}
}
设置运行语言环境
有时我们第一次安装APP时不想默认跟随系统,那么可以通过Xcode的scheme来指定特定语言
Storyboard实时预览
直接上图~
IB中UIImageView国际化无效
解决办法就是为UIImageView
扩展一个方法,然后通过IB中的User Defined Runtime Attributes
把imageName传进去
extension UIImageView{
var local: String {
get{
return ""
}
set(newlocal) {
self.image = localizedImage(newlocal)
}
}
}
IB中UITextView国际化无效
解决办法和UIImageView类似,扩展一个方法,然后把self.text做为key去strings文件中拿相应语言的value
extension UITextView {
var local: Bool {
get{
return true
}
set(newlocale) {
self.text = localized(self.text)
}
}
}
LaunchScreen.xib的国际化
很遗憾,到目前为止,还不支持LaunchScreen.xib的国际化,我们只能通过自定义一个LaunchViewController来完成此需求,但也有些不足,就是应用启动时会黑屏一段时间,所以建议启动页面不要弄国际化
本人目前是一名自由职业者,接受移动两端的项目开发,如果你有需求或者有资源请速与我联系吧,QQ865425695
]]>在开始前我们回忆下传统的Socket编程,里面有Server服务端与Client端的区别。那么在蓝牙编程也是如此,其中Peripheral
外设相当于Socket编程中的Server服务端,Central
中心相当于Client客户端(ps吐槽下,Central中心,作为服务端,不更适合吗!)
你可以理解外设是一个广播数据的设备,它开始告诉外面的世界说它这儿有一些数据,并且能提供一些服务。另一边中心开始扫描周边有没有合适的设备,如果发现后,会和外设做连接请求,一旦连接确定后,两个设备就可以传输数据了。
在iOS6之后,iOS 设备可以是外设,也可以是中心,就像Socket编程中一样,你可以是服务端也可以是客户端。
每个蓝牙4.0的设备都是通过服务和特征来展示自己的,一个设备必然包含一个或多个服务,每个服务下面又包含若干个特征。特征是与外界交互的最小单位。比如说,智能音响设备,用服务A标识播放模块,特征A1来表示播放上一首,特征A2来表示播放下一首;服务B标识设置模块,特征B1设置彩灯颜色。这样做的目的主要为了模块化
。
外设,服务,特征都有一个
UUID
来标识
上面说了设备可以是外设,也可以是中心,也就是会有二种模式
不过在智能家居开发中,大部分硬件蓝牙都是担任外设的角色,也就是说我们应用只要扮演中心即可了。
本篇只讲述第一种模式的本地中心,远程外设端可借助 蓝牙调试神器LightBlue For Mac。需要了解第二种模式可以移步创建外设
更新:LightBlue For Mac只可以做为Central,不可以做为Peripheral,如需模拟请下载iOS版本
蓝牙交互的流程大致为
建立中心角色 —> 扫描外设(discover)—> 发现外设后连接外设(connect) —> 扫描外设中的服务和特征(discover) —> 与外设做数据交互(explore and interact) —> 断开连接(disconnect)。
下面我们一一讲到
在本地中心角色中,使用CBCentralManager类管理,我们创建一个CBCentralManager类
let queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)
let centralMgr = CBCentralManager(delegate: self, queue: queue)
上面的delegate为CBCentralManagerDelegate,后续蓝牙相关的回调都会在此。Queue代表蓝牙在哪个队列里面操作,如果传入nil默认为主队列,值得注意的是后续的回调也是在传入的队列中调用的,所以如果传入的是非主线程的队列,在delegate中需要操作UI时需要手动切换到主线程
CBCentralManager对象创建后会回调到centralManagerDidUpdateState
方法来检测蓝牙可用状态,这时我们可以提醒用户设备是否支持蓝牙,是否打开了蓝牙
let serviceUUIDS: Array<CBUUID> = [CBUUID(string: "FFDD")]
self.centralMgr.scanForPeripheralsWithServices(serviceUUIDS, options: [CBCentralManagerScanOptionAllowDuplicatesKey : true])
//停止扫描
self.centralMgr.stopScan()
如果serviceUUIDS为nil则会扫描周围所有的设外设,否则只会扫描UUID匹配的外设。CBCentralManagerScanOptionAllowDuplicatesKey默认为false,表示扫描中发现过设备则跳过不回调,我们这里传入true,因为下面做外设掉线的处理时需要用到
传入的serviceUUIDS数组元素为CBUUID类型,千万不要传入String,后面的操作也是如此,不然会碰到很多奇葩问题
发现外设后会回调到centralManager(central:didDiscoverPeripheral:advertisementData:RSSI:)
,perpheral则代表着外设,我们需要保存起来,后续的对外设的操作都是基于perpheral对象的
func centralManager(central: CBCentralManager!, didDiscoverPeripheral peripheral: CBPeripheral!, advertisementData: [NSObject : AnyObject]!, RSSI: NSNumber!) {
for i in 0..<discoveredPeripheralers.count {
var peripheraler = discoveredPeripheralers[i]
if(!peripheral.identifier.isEqual(peripheraler.peripheral.identifier)){ //未发现过才保存
discoveredPeripheralers.append(peripheraler)
}
}
}
self.centralMgr.connectPeripheral(peripheral, options: nil)
传入上面保存的外设对象,如果连接失败后会回调到 centralManager(central:didFailToConnectPeripheral:error:)
,连接成功后会回调到 centralManager(central:didConnectPeripheral:)
,这个时候我们只是连接上外设而已,还需要发现外设中的服务与特征
外设连接成功后我们把peripheral保存好,并设置好peripheral的delegate(CBPeripheralDelegate),然后调用discoverServices来发现服务,同扫描外设时一样,discoverServices也可以传入一个serviceUUIDs参数来只获取需要的服务
注意,注意,注意,重要的话说三遍。以下的回调都是CBPeripheralDelegate的了,不再是CBCentralManagerDelegate的回调
func centralManager(central: CBCentralManager!, didConnectPeripheral peripheral: CBPeripheral!) {
self.peripheral = peripheral
self.peripheral.delegate = self
let serviceUUIDS: Array<CBUUID> = [CBUUID(string: "FF12")]
self.peripheral.discoverServices(serviceUUIDS)
}
发现服务后回调到peripheral(peripheral:didDiscoverServices:)
,这时我们就可以访问所有发现的服务一一去发现服务下的特征
func peripheral(peripheral: CBPeripheral!, didDiscoverServices error: NSError!) {
if(error != nil) {
log(error)
return
}
for item in peripheral.services {
let service = item as! CBService
let characteristicUUIDs: Array<CBUUID> = [CBUUID(string: "FF02"), CBUUID(string: "FF04")]
peripheral.discoverCharacteristics(characteristicUUIDs, forService: service) //发现特征
}
}
同样特征也可以传入characteristicUUIDs数组来过滤,发现特征后回调
func peripheral(peripheral: CBPeripheral!, didDiscoverCharacteristicsForService service: CBService!, error: NSError!) {
if(error != nil){
log(error)
return
}
for item in service.characteristics {
let characteristic = item as! CBCharacteristic
if(characteristic.properties == .Notify) { //如果特征为订阅属性则开启订阅
peripheral.setNotifyValue(true, forCharacteristic: characteristic)
}
}
}
每进入一次回调代表发现一个服务中的特征而不是外设所有的特征,外设、服务、特征从左至右都是上下级一对多的关系。
每个特征都有个属性,代表着它是可写、可读等,一个特征可同时拥有读写权限,如上面的订阅其实是一种订阅者模式的读取数据
拿到可写的特征后,通过writeValue发送数据包
let data = "hello".dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: true)
//自动判断写特征的类型
var type: CBCharacteristicWriteType = .WithoutResponse
if(writeCharacteristic.properties == CBCharacteristicProperties.Write) {
type = .WithResponse
}
self.peripheral!.writeValue(data, forCharacteristic: writeCharacteristic, type: type)
把要发送的文本转换为二进制,发送到相应的特征即可。值得注意的是第三个参数type写类型需要与特征的属性一致,其中WithoutResponse与WithResponse区别在于前者发送数据后是没有回调的,后者会回调到 peripheral(peripheral:didWriteValueForCharacteristic:error:)
来检测是否发送成功,如果发送数据传入的类型与特征不同时总是会失败
由于蓝牙的缓冲大小只有20bytes,那么如果我们发送的数据包大小不能大于20bytes,所以得分多次发送
func writeValue(data: NSData, withCharacteristic characteristic: CBCharacteristic) -> Bool {
if(self.peripheral == nil) {
return false
}
var didSend = false
var sendDataIndex = 0
let NOTIFY_MTU = 20
while (data.length - sendDataIndex != 0) {
//剩下的数据大小
var amountToSend = data.length - sendDataIndex
// 不能大于20bytes
if (amountToSend > NOTIFY_MTU) {
amountToSend = NOTIFY_MTU
}
let chunk = NSData(bytes: data.bytes + sendDataIndex, length: amountToSend)
var type: CBCharacteristicWriteType = .WithoutResponse
if(characteristic.properties == CBCharacteristicProperties.Write) {
type = .WithResponse
}
self.peripheral!.writeValue(chunk, forCharacteristic: characteristic, type: type)
sendDataIndex += amountToSend
}
return true
}
分为二种,直接读、订阅,顾名思义,直接读就是手动调用API读取,订阅则只要开启后,外设有消息都可以收到
直接读
self.peripheral!.readValueForCharacteristic(characteristic)
订阅
self.peripheral!.setNotifyValue(true, forCharacteristic: characteristic)
两种回调都会回调到 peripheral(peripheral:didUpdateValueForCharacteristic:error:)
,上面也提到因为蓝牙的缓冲大小,需要发送多次,那么在读取时也需要接收多次,才能保证数据的一体性,所以通常都会在数据包的开始用 EOM
来标识一段数据的开始,数据结束后再次用 EOM
来标识,所以我们接收数据时会这样
let updatingEOMFlag = "EOM"
func peripheral(peripheral: CBPeripheral!, didUpdateValueForCharacteristic characteristic: CBCharacteristic!, error: NSError!) {
if(error != nil) {
log(error)
return
}
if(characteristic.value != nil) {
var data = characteristic.value!
var string = NSString(data: data, encoding: NSUTF8StringEncoding)
log(string)
//接收多段数据
if(self.updatingEOMFlag != nil) {
if(self.updatingEOMFlag == string) {
var EOMEndFlag = false
for i in 0..<self.updatingDatas.count { //数据结束
var updatingData = self.updatingDatas[i]
if(updatingData.characteristic.UUID.isEqual(characteristic.UUID)) {
data = updatingData.data
string = NSString(data: data, encoding: NSUTF8StringEncoding)
self.updatingDatas.removeAtIndex(i) //删除缓存数据
EOMEndFlag = true
break
}
}
if(!EOMEndFlag) {//数据开始
let updatingData = UpdatingDataer(characteristic: characteristic, data: NSMutableData())
self.updatingDatas!.append(updatingData)
return
}
} else {
if var updatingData = (self.updatingDatas?.filter{ $0.characteristic.UUID.isEqual(characteristic.UUID) }) where updatingData.count == 1 && updatingData[0].data != nil { //数据中间
updatingData[0].data.appendData(data)
return
}
}
}
//在此最终得到完整数据
let stringData = StringData(string: string as? String, data: data)
//触发delegate与通知回调
...
}
}
self.centralMgr.cancelPeripheralConnection(self.peripheral!)
至此,整个流程就完了
所谓掉线就是外设发现了后,过了一段时间失去信号了。喵了下系统框架,没有找到相关外设掉线的检测,唯一有点像的就是发现外设里面的RSSI,代表设备信号强度,值越小信息越好。
中央Central
的角色,设备扮演外设Peripheral
的角色更新: 提供了一个读写的Central端Demo,Peripheral端请用上述iOS版LightBlue模拟
Core Bluetooth Programming Guide
本人目前是一名自由职业者,接受移动两端的项目开发,如果你有需求或者有资源请速与我联系吧,QQ865425695
]]>Runtime Attributes
来动态注入(实则为KVO
实现)
但有个别属性设置你会发现怎样都无效
如:layer.borderColor
原因在于borderColor
接受的参数是CGColor类型,而此处的Color为UIColor,所以导致注入时参数类型不一致
中间属性器
来转换一下类型就OK了1、自定义代码
extension CALayer{
//解决IB中runtime attribute中layer.borderColor不能转换UIColor为CGColor
var borderColorFromUIColor: UIColor {
set(color){
self.borderColor = color.CGColor;
}
get{
return UIColor(CGColor: self.borderColor)
}
}
}
注意,swift与OC的属性设置器不同,OC中是setXXX,而swift是有内部setter
2、IB中设置,把原先的layer.borderColor
改为layer.borderColorFromUIColor
Hello,new Blog~
之前一直都是在csdn上写博客(其实也没怎么写(⊙o⊙)…),最近订了些目标,个人网站就是其中一项
so~ Just do it!
]]>