最近在 Cocoa 应用程序里嵌入 Ruby1.9 做文本相关的处理. 由于 ObjC 和 Ruby 对象模型的相似之处, 比以前在 wxWidgets 里和 C++ 交互爽多了, 故来分享一下心得.
嵌入 VM 的方式基本和镐头书里介绍的差不多, 写了个 class 把相关代码放在一起. (Xcode 中要把 libruby.1.9.1-static.a 也包含进来, 在项目配置的 header search paths 里加上 path-to-ruby/include/ruby-1.9.1
)
#import "RubyVM.h"
#import "NSString+StringWithVALUE.h"
#include <ruby.h>
#define FORMAT(...) [NSString stringWithFormat:__VA_ARGS__]
RUBY_GLOBAL_SETUP;
@implementation RubyVM
+ (void)initVM
{
int argc = 0;
char** argv = NULL;
ruby_sysinit(&argc, &argv);
RUBY_INIT_STACK;
ruby_init();
// 这两个方法做的事情稍后解释
[RubyVM my_init_loadpath];
[RubyVM my_init_prelude];
ruby_script("my_script_name");
}
#pragma mark - private
...
@end
RUBY_GLOBAL_SETUP, ruby_sysinit, RUBY_INIT_STACK, ruby_init
是不可缺少的步骤, 无需解释. ruby_script()
用于设定脚本名字, 虽然这个名字完全不影响 require 进来的脚本里 __FILE__
的结果, 但是如果不设一个名字, 在使用 __FILE__
的地方就会出错.
loadpath 和 prelude 需要一些额外的处理.
Ruby 的初始 loadpath 包含 Ruby 目录里的 lib/1.9.1/#{平台}, lib, site_ruby
等目录. 但是很可惜的是, 除 windows 外, 这些路径是在编译 Ruby 之前就写死的 (距已故的 Guy Decoux 解释, 是为了安全性... windows 反正本来不安全就不管了...), 调用 ruby_init_path()
的话会把这些写死的路径都放到 loadpath 中去. 但是发布桌面应用不可能让全部用户都装一个 rvm, 标准库一般还是打包到应用程序的 Frameworks 目录里, 下面 my_init_load_path
使用 Cocoa 的 NSBundle 工具把正确的 lib 路径找出来交给 Ruby.
+ (void)my_init_loadpath
{
NSString* bundlePath = [[NSBundle mainBundle] bundlePath];
NSString* script;
// ruby_init_loadpath() hard coded load paths, need to roll our own
NSString* libDir = [bundlePath stringByAppendingString:@"/Frameworks/ruby-lib"];
script = FORMAT(@"$:.unshift '%@/x86_64-darwin12.0.0'\n"
@"$:.unshift '%@'", libDir, libDir);
rb_eval_string([script cStringUsingEncoding:NSUTF8StringEncoding]);
// prepare GEM_HOME for loading rubygems
NSString* gemDir = [bundlePath stringByAppendingString:@"/Frameworks/ruby-gems"];
script = FORMAT(@"ENV['GEM_HOME'] = '%@'", gemDir);
rb_eval_string([script cStringUsingEncoding:NSUTF8StringEncoding]);
}
要在 XCode 中把标准库打包到发布程序中, 可以参考这里添加一个 build step (由于我把标准库拷了一份到项目源代码下面, 所以是从 $SRCROOT
而不是 rvm 目录开始复制)
Ruby 源码里是通过 ruby.c
中的 cmdline_options_init()
函数确定内外编码, 然后才通过 Init_prelude()
载入转码数据库的. 实际可以如下面 my_init_prelude
方法直接把内外编码都设成 UTF-8 即可. Ruby 的 Init_prelude()
函数搞定其余事情: 定义 Mutex, 载入编码数据库, 并且根据环境变量 GEM_HOME
初始化 RubyGems 的搜索位置.
#include <ruby/encoding.h>
extern void Init_prelude(void);
...
+ (void)my_init_prelude
{
rb_encoding *enc = rb_utf8_encoding();
rb_enc_set_default_external(rb_enc_from_encoding(enc));
rb_enc_set_default_internal(rb_enc_from_encoding(enc));
rb_define_module("Gem");
Init_prelude();
}
嵌入 Ruby 时, GUI 对象的生命周期是尤其要注意的. 例如一个文本框 NSTextField
被移除出窗体后, 它的引用数变成 0 就回收了, 但是包装了它的 Ruby 对象并不知情, 一访问就 segfault 了. 一个避免 segfault 方案是在移除 GUI 对象之前让 Ruby 对象终止所有的关联处理, 然后在 dealloc
方法中把关联的 Ruby 对象也干掉. 另一个方案是避免 Data_Wrap_Struct()
, 改用 "软一点" 的方式去相互引用 (例如两者都位于各自所在环境的一个数组的相同 index 中). 如果让 Ruby 对象响应 GUI 事件, 那么在 Ruby 处理事件前最好就把所有的可能用到的参数都准备好, Ruby 处理事件中要避免回访可能被移除的 GUI 对象, 如果不能避免, 就想办法 (例如锁) 告诉其他部分的 ObjC 程序: 事件未理完, 刀下请留人...
关于 Cocoa 字符串的内部表示, 在 64 位 intel 上, NSUnicodeEncoding 对应 Ruby 的 UTF-16LE, 但字符串长度不考虑 surrogate pair, 而 Ruby 是考虑 surrogate pair 的. 下面代码区别的原因就在于 "𝄞" 这个字符的 unicode 是大于 65535 的, 在 utf-16 中必须用两个码点也就是 4 字节才能表示:
ruby:
"𝄞".encode('utf-16le').length //=> 1
objc:
@"𝄞".length //=> 2
双方共享字符串的时候, 要注意这个区别. Cocoa 的 "character" 概念, 其实是一般 unicode 文档中说的码点, 而 Cocoa 中的 "glyph" 才对应平常我们说的 "字符". 如果要在 Cocoa 中计算字符串的字符数, 一种办法是让 ruby 解决, 一种办法是用 glyph 相关的 API (请参考 core text 相关文档), 还有一种办法是用 UTF32 (UTF32 一个码点能表示的字符范围有几十亿, 现在和将来好长一段时间码点和字符都是一一对应的)
size_t len = [@"𝄞" lengthOfBytesUsingEncoding:NSUTF32StringEncoding] / 4 //=> 1
后来尝试了嵌入 Ruby 2.0, 写法简化了一些: 不用写 RUBY_GlOBAL_SETUP 了. 但是载入 prelude 处理到 encdb 时会崩溃, 然后连进入 debug 模式的 xcode 都会一起崩溃掉... 以后嵌入 API 可能也一并修改, 估计 2.0.x 可以修正, 应关注 这个 issue
为什么不嵌 mruby: 因为我的应用需要 Fiber 但 mruby 还不支持. 为什么不嵌 Xcode 带的 Ruby Framework: 因为那是 1.8 的. 为什么不嵌 MacRuby: 因为嵌入的目的是发挥 Ruby 的长处 (字符串, 标准库, gem...), 用 Ruby 写 GUI 的话巨长的方法名会让你抓狂的.