元宇宙(Metaverse),是人类运用数字技术构建的,由现实世界映射或超越现实世界,可与现实世界交互的虚拟世界,具备新型社会体系的数字生活空间。
可见元宇宙第一步是创建专属虚拟形象,但创建3D虚拟形象需要3D基础知识。对于大部分android开发者(包括我本人)来说没有这方面的积累。难道因此我们就难以进入元宇宙的世界吗?不,今天我们借助即构平台提供的Avatar SDK,只要有Android基础即可进入最火的元宇宙世界!先看效果:

上面gif被压缩的比较狠,这里放一张截图:

前往即构控制台网站:https://console.zego.im/注册开发者账户。注册成功后,创建项目:

控制台中可以得到AppID和AppSign两个数据,这两个数据是重要凭证,后面会用到。
由于我们用到了即构的Avatar功能,但目前官方没有提供线上自动开启方式,需要主动找客服申请(当然,这是免费的),只需提供自己项目的包名,即可开通Avatar权限。打开https://doc-zh.zego.im/article/15206右下角有“联系我们”,点击即可跟客服申请免费开通权限。
注意,如果不向客服申请Avatar权限,调用AvatarSDK会失败!
前往即构官方元宇宙开发SDK网站https://doc-zh.zego.im/article/15302下载SDK,得到如下文件列表:

接下来过程如下:
SDK目录,将里面的ZegoAvatar.aar拷贝至app/libs目录下。SDK引用。打开app/build.gradle文件,在dependencies节点引入 libs下所有的jar和aar:
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', "*.aar"]) //通配引入
//其他略
}
app/src/main/AndroidManifest.xml 文件,添加权限。<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-feature
android:glEsVersion="0x00020000"
android:required="true" />
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
因为
Android 6.0在一些比较重要的权限上要求必须申请动态权限,不能只通过AndroidMainfest.xml文件申请静态权限。具体动态请求权限代码可看附件源码。
上一小节下载的zip文件中,有个assets目录。里面包含了Avatar形象相关资源,如:衣服、眉毛、鞋子等。这是即构官方免费提供的资源,可以满足一般性需求了。当然了,如果想要自己定制资源也是可以的。assets文件内容如下:

| 资源名称 | 说明 |
|---|---|
| AIModel.bundle | Avatar 的 AI 模型资源。当使用表情随动、声音随动、AI 捏脸等能力时,必须先将该资源的绝对路径设置给 Avatar SDK。 |
| base.bundle | 美术资源,包含基础 3D 人物模型资源、资源映射表、人物模型默认外形等。 |
| Packages | 美妆、挂件、装饰等资源。 每个资源 200 KB ~ 1 MB 不等,跟资源复杂度相关。 |
注意:由于资源文件很大,上面下载的美术资源只包含少量必须资源。如果需要全部资源,可以去官网找客服索要:https://doc-zh.zego.im/article/14882
上面的文件需要存放到Android本地SDCard上,这里有2个方案可供参考:
app/src/assets目录内,然后在app启动时,自动将assets的内容拷贝到SDcard中。为了简单起见,我们这里采用方案一。参考代码如下, 详细代码可以看附件:
AssetsFileTransfer.copyAssetsDir2Phone(this.getApplication(),
"AIModel.bundle", "assets");
AssetsFileTransfer.copyAssetsDir2Phone(this.getApplication(),
"base.bundle", "assets");
AssetsFileTransfer.copyAssetsDir2Phone(this.getApplication(),
"Packages", "assets");
创建虚拟形象本质上来说就是调用即构的Avatar SDK,其大致流程如下:

接下来我们逐步实现上面流程。
需要注意的是,上面示意图中采用的是AvatarView,可以非常方便的直接展示Avatar形象,但是不方便后期将画面通过RTC实时传递, 因此,我们后面的具体实现视通过TextureView替代AvatarView。
这里再次强调一下,一定要打开https://doc-zh.zego.im/article/15206点击右下角有“联系我们”,向客服申请免费开通Avatar权限。否则无法使用Avatar SDK。
申请权鉴代码如下:
public class KeyCenter {
// 控制台地址: https://console.zego.im/dashboard
public static long APP_ID = 这里值可以在控制台查询,参考第一节; //这里填写APPID
public static String APP_SIGN = 这里值可以在控制台查询,参考第一节;
// 鉴权服务器的地址
public final static String BACKEND_API_URL = "https://aieffects-api.zego.im?Action=DescribeAvatarLicense";
public static String avatarLicense = null;
public static String getURL(String authInfo) {
Uri.Builder builder = Uri.parse(BACKEND_API_URL).buildUpon();
builder.appendQueryParameter("AppId", String.valueOf(APP_ID));
builder.appendQueryParameter("AuthInfo", authInfo);
return builder.build().toString();
}
public interface IGetLicenseCallback {
void onGetLicense(int code, String message, ZegoLicense license);
}
/**
* 在线拉取 license
* @param context
* @param callback
*/
public static void getLicense(Context context, final IGetLicenseCallback callback) {
requestLicense(ZegoAvatarService.getAuthInfo(APP_SIGN, context), callback);
}
/**
* 获取license
* */
public static void requestLicense(String authInfo, final IGetLicenseCallback callback) {
String url = getURL(authInfo);
HttpRequest.asyncGet(url, ZegoLicense.class, (code, message, responseJsonBean) -> {
if (callback != null) {
callback.onGetLicense(code, message, responseJsonBean);
}
});
}
public class ZegoLicense {
@SerializedName("License")
private String license;
public String getLicense() {
return license;
}
public void setLicense(String license) {
this.license = license;
}
}
}
在获取Lincense时,只需调用getLicense函数,例如在Activity类中只需如下调用:
KeyCenter.getLicense(this, (code, message, response) -> {
if (code == 0) {
KeyCenter.avatarLicense = response.getLicense();
showLoading("正在初始化...");
avatarMngr = AvatarMngr.getInstance(getApplication());
avatarMngr.setLicense(KeyCenter.avatarLicense, this);
} else {
toast("License 获取失败, code: " + code);
}
});
初始化AvatarService过程比较漫长(可能要几秒),通过开启worker线程后台加载以避免主线程阻塞。因此我们定义一个回调函数,待完成初始化后回调通知:
public interface OnAvatarServiceInitSucced {
void onInitSucced();
}
public void setLicense(String license, OnAvatarServiceInitSucced listener) {
this.listener = listener;
ZegoAvatarService.addServiceObserver(this);
String aiPath = FileUtils.getPhonePath(mApp, "AIModel.bundle", "assets"); // AI 模型的绝对路径
ZegoServiceConfig config = new ZegoServiceConfig(license, aiPath);
ZegoAvatarService.init(mApp, config);
}
@Override
public void onStateChange(ZegoAvatarServiceState state) {
if (state == ZegoAvatarServiceState.InitSucceed) {
Log.i("ZegoAvatar", "Init success");
// 要记得及时移除通知
ZegoAvatarService.removeServiceObserver(this);
if (listener != null) listener.onInitSucced();
}
}
这里setLicense函数内完成初始化AvatarService,初始化完成后会回调onStateChange函数。但是要注意,在初始化之前必须把资源文件拷贝到本地SDCard,即完成资源导入:
private void initRes(Application app) {
// 先把资源拷贝到SD卡,注意:线上使用时,需要做一下判断,避免多次拷贝。资源也可以做成从网络下载。
if (!FileUtils.checkFile(app, "AIModel.bundle", "assets"))
FileUtils.copyAssetsDir2Phone(app, "AIModel.bundle", "assets");
if (!FileUtils.checkFile(app, "base.bundle", "assets"))
FileUtils.copyAssetsDir2Phone(app, "base.bundle", "assets");
if (!FileUtils.checkFile(app, "human.bundle", "assets"))
FileUtils.copyAssetsDir2Phone(app, "human.bundle", "assets");
if (!FileUtils.checkFile(app, "Packages", "assets"))
FileUtils.copyAssetsDir2Phone(app, "Packages", "assets");
}
前面在https://doc-zh.zego.im/article/15302下载SDK包含了helper目录,这个目录里面有非常重要的两个文件:

其中ZegoCharacterHelper文件是个接口定义类,即虽然是个类,但具体的实现全部在ZegoCharacterHelperImpl中。我们先一睹为快,看看ZegoCharacterHelper包含了哪些可处理的属性:
public class ZegoCharacterHelper {
public static final String MODEL_ID_MALE = "male";
public static final String MODEL_ID_FEMALE = "female";
//****************************** 捏脸维度的 key 值 ******************************/
public static final String FACESHAPE_BROW_SIZE_Y = "faceshape_brow_size_y";// 眉毛厚度, 取值范围0.0-1.0,默认值0.5
public static final String FACESHAPE_BROW_SIZE_X = "faceshape_brow_size_x";// 眉毛长度, 取值范围0.0-1.0,默认值0.5
public static final String FACESHAPE_BROW_ALL_Y = "faceshape_brow_all_y";// 眉毛高度, 取值范围0.0-1.0,默认值0.5
public static final String FACESHAPE_BROW_ALL_ROLL_Z = "faceshape_brow_all_roll_z";// 眉毛旋转, 取值范围0.0-1.0,默认值0.5
public static final String FACESHAPE_EYE_SIZE = "faceshape_eye_size"; // 眼睛大小, 取值范围0.0-1.0,默认值0.5
public static final String FACESHAPE_EYE_SIZE_Y = "faceshape_eye_size_y";
public static final String FACESHAPE_EYE_ROLL_Y = "faceshape_eye_roll_y";// 眼睛高度, 取值范围0.0-1.0,默认值0.5
public static final String FACESHAPE_EYE_ROLL_Z = "faceshape_eye_roll_z";// 眼睛旋转, 取值范围0.0-1.0,默认值0.5
public static final String FACESHAPE_EYE_X = "faceshape_eye_x";// 双眼眼距, 取值范围0.0-1.0,默认值0.5
public static final String FACESHAPE_NOSE_ALL_X = "faceshape_nose_all_x";// 鼻子宽度, 取值范围0.0-1.0,默认值0.5
public static final String FACESHAPE_NOSE_ALL_Y = "faceshape_nose_all_y";// 鼻子高度, 取值范围0.0-1.0,默认值0.5
public static final String FACESHAPE_NOSE_SIZE_Z = "faceshape_nose_size_z";
public static final String FACESHAPE_NOSE_ALL_ROLL_Y = "faceshape_nose_all_roll_y";// 鼻头旋转, 取值范围0.0-1.0,默认值0.5
public static final String FACESHAPE_NOSTRIL_ROLL_Y = "faceshape_nostril_roll_y";// 鼻翼旋转, 取值范围0.0-1.0,默认值0.5
public static final String FACESHAPE_NOSTRIL_X = "faceshape_nostril_x";
public static final String FACESHAPE_MOUTH_ALL_Y = "faceshape_mouth_all_y";// 嘴巴上下, 取值范围0.0-1.0,默认值0.5
public static final String FACESHAPE_LIP_ALL_SIZE_Y = "faceshape_lip_all_size_y";// 嘴唇厚度, 取值范围0.0-1.0,默认值0.5
public static final String FACESHAPE_LIPCORNER_Y = "faceshape_lipcorner_y";// 嘴角旋转, 取值范围0.0-1.0,默认值0.5
public static final String FACESHAPE_LIP_UPPER_SIZE_X = "faceshape_lip_upper_size_x"; // 上唇宽度, 取值范围0.0-1.0,默认值0.5
public static final String FACESHAPE_LIP_LOWER_SIZE_X = "faceshape_lip_lower_size_x"; // 下唇宽度, 取值范围0.0-1.0,默认值0.5
public static final String FACESHAPE_JAW_ALL_SIZE_X = "faceshape_jaw_all_size_x";// 下巴宽度, 取值范围0.0-1.0,默认值0.5
public static final String FACESHAPE_JAW_Y = "faceshape_jaw_y";// 下巴高度, 取值范围0.0-1.0,默认值0.5
public static final String FACESHAPE_CHEEK_ALL_SIZE_X = "faceshape_cheek_all_size_x";// 脸颊宽度, 取值范围0.0-1.0,默认值0.5
//其他函数略
}
可以看到,上面数据基本包含所有人脸属性了,基本具备了捏脸能力,篇幅原因,我们这里不具体去实现。有这方面需求的读者,可以通过在界面上调整上面相关属性来实现。
接下来我们开始创建虚拟形象,首先创建一个User实体类:
public class User {
public String userName; //用户名
public String userId; //用户ID
public boolean isMan; //性别
public int width; //预览宽度
public int height; //预览高度
public int bgColor; //背景颜色
public int shirtIdx = 0; // T-shirt资源id
public int browIdx = 0; //眉毛资源id
public User(String userName, String userId, int width, int height) {
this.userName = userName;
this.userId = userId;
this.width = width;
this.height = height;
this.isMan = true;
bgColor = Color.argb(255, 33, 66, 99);
}
}
示例作用,为了简单起见,我们这里只针对眉毛和衣服资源做选取。接下来创建一个Activity:
public class AvatarActivity extends BaseActivity {
private int vWidth = 720;
private int vHeight = 1080;
private User user = new User("C_0001", "C_0001", vWidth, vHeight);
private TextureView mTextureView; //用于显示Avatar形象
private AvatarMngr mAvatarMngr; // 用于维护管理Avatar
private ColorPickerDialog colorPickerDialog; //用于背景色选取
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_avatar);
// ....
// 其他初始化界面相关代码略...
// ....
user.isMan = true;
mTextureView = findViewById(R.id.avatar_view);
mZegoMngr = ZegoMngr.getInstance(getApplication());
// 开启虚拟形象预览
mAvatarMngr.start(mTextureView, user);
}
最后一行代码中开启了虚拟形象预览,那么这里开启虚拟形象预览具体做了哪些工作呢?show me the code:
public class AvatarMngr implements ZegoAvatarServiceDelegate, RTCMngr.CaptureListener {
private static final String TAG = "AvatarMngr";
private static AvatarMngr mInstance;
private boolean mIsStop = false;
private User mUser = null;
private TextureBgRender mBgRender = null;
private OnAvatarServiceInitSucced listener;
private ZegoCharacterHelper mCharacterHelper;
private Application mApp;
public interface OnAvatarServiceInitSucced {
void onInitSucced();
}
public void setLicense(String license, OnAvatarServiceInitSucced listener) {
this.listener = listener;
ZegoAvatarService.addServiceObserver(this);
String aiPath = FileUtils.getPhonePath(mApp, "AIModel.bundle", "assets"); // AI 模型的绝对路径
ZegoServiceConfig config = new ZegoServiceConfig(license, aiPath);
ZegoAvatarService.init(mApp, config);
}
public void stop() {
mIsStop = true;
mUser = null;
stopExpression();
}
public void updateUser(User user) {
mUser = user;
if (user.shirtIdx == 0) {
mCharacterHelper.setPackage("m-shirt01");
} else {
mCharacterHelper.setPackage("m-shirt02");
}
if (user.browIdx == 0) {
mCharacterHelper.setPackage("brows_1");
} else {
mCharacterHelper.setPackage("brows_2");
}
}
/**
* 启动Avatar,调用此函数之前,请确保已经调用过setLicense
*
* @param avatarView
*/
public void start(TextureView avatarView, User user) {
mUser = user;
mIsStop = false;
initAvatar(avatarView, user);
startExpression();
}
private void initAvatar(TextureView avatarView, User user) {
String sex = ZegoCharacterHelper.MODEL_ID_MALE;
if (!user.isMan) sex = ZegoCharacterHelper.MODEL_ID_FEMALE;
// 创建 helper 简化调用
// base.bundle 是头模, human.bundle 是全身人模
mCharacterHelper = new ZegoCharacterHelper(FileUtils.getPhonePath(mApp, "human.bundle", "assets"));
mCharacterHelper.setExtendPackagePath(FileUtils.getPhonePath(mApp, "Packages", "assets"));
// 设置形象配置
mCharacterHelper.setDefaultAvatar(sex);
updateUser(user);
// 获取当前妆容数据, 可以保存到用户资料中
String json = mCharacterHelper.getAvatarJson();
}
// 启动表情检测
private void startExpression() {
// 启动表情检测前要申请摄像头权限, 这里是在 MainActivity 已经申请过了
ZegoAvatarService.getInteractEngine().startDetectExpression(ZegoExpressionDetectMode.Camera, expression -> {
// 表情直接塞给 avatar 驱动
mCharacterHelper.setExpression(expression);
});
}
// 停止表情检测
private void stopExpression() {
// 不用的时候记得停止
ZegoAvatarService.getInteractEngine().stopDetectExpression();
}
// 获取到 avatar 纹理后的处理
public void onCaptureAvatar(int textureId, int width, int height) {
if (mIsStop || mUser == null) { // rtc 的 onStop 是异步的, 可能activity已经运行到onStop了, rtc还没
return;
}
boolean useFBO = true;
if (mBgRender == null) {
mBgRender = new TextureBgRender(textureId, useFBO, width, height, Texture2dProgram.ProgramType.TEXTURE_2D_BG);
}
mBgRender.setInputTexture(textureId);
float r = Color.red(mUser.bgColor) / 255f;
float g = Color.green(mUser.bgColor) / 255f;
float b = Color.blue(mUser.bgColor) / 255f;
float a = Color.alpha(mUser.bgColor) / 255f;
mBgRender.setBgColor(r, g, b, a);
mBgRender.draw(useFBO); // 画到 fbo 上需要反向的
ZegoExpressEngine.getEngine().sendCustomVideoCaptureTextureData(mBgRender.getOutputTextureID(), width, height, System.currentTimeMillis());
}
@Override
public void onStartCapture() {
if (mUser == null) return;
// // 收到回调后,开发者需要执行启动视频采集相关的业务逻辑,例如开启摄像头等
AvatarCaptureConfig config = new AvatarCaptureConfig(mUser.width, mUser.height);
// // 开始捕获纹理
mCharacterHelper.startCaptureAvatar(config, this::onCaptureAvatar);
}
@Override
public void onStopCapture() {
mCharacterHelper.stopCaptureAvatar();
stopExpression();
}
private void initRes(Application app) {
// 先把资源拷贝到SD卡,注意:线上使用时,需要做一下判断,避免多次拷贝。资源也可以做成从网络下载。
if (!FileUtils.checkFile(app, "AIModel.bundle", "assets"))
FileUtils.copyAssetsDir2Phone(app, "AIModel.bundle", "assets");
if (!FileUtils.checkFile(app, "base.bundle", "assets"))
FileUtils.copyAssetsDir2Phone(app, "base.bundle", "assets");
if (!FileUtils.checkFile(app, "human.bundle", "assets"))
FileUtils.copyAssetsDir2Phone(app, "human.bundle", "assets");
if (!FileUtils.checkFile(app, "Packages", "assets"))
FileUtils.copyAssetsDir2Phone(app, "Packages", "assets");
}
@Override
public void onError(ZegoAvatarErrorCode code, String desc) {
Log.e(TAG, "errorcode : " + code.getErrorCode() + ",desc : " + desc);
}
@Override
public void onStateChange(ZegoAvatarServiceState state) {
if (state == ZegoAvatarServiceState.InitSucceed) {
Log.i("ZegoAvatar", "Init success");
// 要记得及时移除通知
ZegoAvatarService.removeServiceObserver(this);
if (listener != null) listener.onInitSucced();
}
}
private AvatarMngr(Application app) {
mApp = app;
initRes(app);
}
public static AvatarMngr getInstance(Application app) {
if (null == mInstance) {
synchronized (AvatarMngr.class) {
if (null == mInstance) {
mInstance = new AvatarMngr(app);
}
}
}
return mInstance;
}
}
以上代码完成了整个虚拟形象的创建,关键代码全部展示,如果还需要具体全部代码,直接从附件中下载即可。
我正在学习如何使用Nokogiri,根据这段代码我遇到了一些问题:require'rubygems'require'mechanize'post_agent=WWW::Mechanize.newpost_page=post_agent.get('http://www.vbulletin.org/forum/showthread.php?t=230708')puts"\nabsolutepathwithtbodygivesnil"putspost_page.parser.xpath('/html/body/div/div/div/div/div/table/tbody/tr/td/div
总的来说,我对ruby还比较陌生,我正在为我正在创建的对象编写一些rspec测试用例。许多测试用例都非常基础,我只是想确保正确填充和返回值。我想知道是否有办法使用循环结构来执行此操作。不必为我要测试的每个方法都设置一个assertEquals。例如:describeitem,"TestingtheItem"doit"willhaveanullvaluetostart"doitem=Item.new#HereIcoulddotheitem.name.shouldbe_nil#thenIcoulddoitem.category.shouldbe_nilendend但我想要一些方法来使用
出于纯粹的兴趣,我很好奇如何按顺序创建PI,而不是在过程结果之后生成数字,而是让数字在过程本身生成时显示。如果是这种情况,那么数字可以自行产生,我可以对以前看到的数字实现垃圾收集,从而创建一个无限系列。结果只是在Pi系列之后每秒生成一个数字。这是我通过互联网筛选的结果:这是流行的计算机友好算法,类机器算法:defarccot(x,unity)xpow=unity/xn=1sign=1sum=0loopdoterm=xpow/nbreakifterm==0sum+=sign*(xpow/n)xpow/=x*xn+=2sign=-signendsumenddefcalc_pi(digits
关闭。这个问题是opinion-based.它目前不接受答案。想要改进这个问题?更新问题,以便editingthispost可以用事实和引用来回答它.关闭4年前。Improvethisquestion我想在固定时间创建一系列低音和高音调的哔哔声。例如:在150毫秒时发出高音调的蜂鸣声在151毫秒时发出低音调的蜂鸣声200毫秒时发出低音调的蜂鸣声250毫秒的高音调蜂鸣声有没有办法在Ruby或Python中做到这一点?我真的不在乎输出编码是什么(.wav、.mp3、.ogg等等),但我确实想创建一个输出文件。
给定这段代码defcreate@upgrades=User.update_all(["role=?","upgraded"],:id=>params[:upgrade])redirect_toadmin_upgrades_path,:notice=>"Successfullyupgradeduser."end我如何在该操作中实际验证它们是否已保存或未重定向到适当的页面和消息? 最佳答案 在Rails3中,update_all不返回任何有意义的信息,除了已更新的记录数(这可能取决于您的DBMS是否返回该信息)。http://ar.ru
我在我的项目目录中完成了compasscreate.和compassinitrails。几个问题:我已将我的.sass文件放在public/stylesheets中。这是放置它们的正确位置吗?当我运行compasswatch时,它不会自动编译这些.sass文件。我必须手动指定文件:compasswatchpublic/stylesheets/myfile.sass等。如何让它自动运行?文件ie.css、print.css和screen.css已放在stylesheets/compiled。如何在编译后不让它们重新出现的情况下删除它们?我自己编译的.sass文件编译成compiled/t
我正在寻找执行以下操作的正确语法(在Perl、Shell或Ruby中):#variabletoaccessthedatalinesappendedasafileEND_OF_SCRIPT_MARKERrawdatastartshereanditcontinues. 最佳答案 Perl用__DATA__做这个:#!/usr/bin/perlusestrict;usewarnings;while(){print;}__DATA__Texttoprintgoeshere 关于ruby-如何将脚
Rackup通过Rack的默认处理程序成功运行任何Rack应用程序。例如:classRackAppdefcall(environment)['200',{'Content-Type'=>'text/html'},["Helloworld"]]endendrunRackApp.new但是当最后一行更改为使用Rack的内置CGI处理程序时,rackup给出“NoMethodErrorat/undefinedmethod`call'fornil:NilClass”:Rack::Handler::CGI.runRackApp.newRack的其他内置处理程序也提出了同样的反对意见。例如Rack
使用带有Rails插件的vim,您可以创建一个迁移文件,然后一次性打开该文件吗?textmate也可以这样吗? 最佳答案 你可以使用rails.vim然后做类似的事情::Rgeneratemigratonadd_foo_to_bar插件将打开迁移生成的文件,这正是您想要的。我不能代表textmate。 关于ruby-使用VimRails,您可以创建一个新的迁移文件并一次性打开它吗?,我们在StackOverflow上找到一个类似的问题: https://sta
在选择我想要运行操作的频率时,唯一的选项是“每天”、“每小时”和“每10分钟”。谢谢!我想为我的Rails3.1应用程序运行调度程序。 最佳答案 这不是一个优雅的解决方案,但您可以安排它每天运行,并在实际开始工作之前检查日期是否为当月的第一天。 关于ruby-如何每月在Heroku运行一次Scheduler插件?,我们在StackOverflow上找到一个类似的问题: https://stackoverflow.com/questions/8692687/