最近在 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 的话巨长的方法名会让你抓狂的。