草庐IT

android - Android 上 SurfaceView 方法 "onTouchEvent(...)"中的 ANR

coder 2023-12-27 原文

在 Android 上,我对 SurfaceView 进行了子类化,生成的 View 在大多数情况下工作正常。但是,大约 1% 的用户报告此实现存在 ANR 问题。

显然,存在一种边缘情况,其中 SurfaceView 由于某些问题(可能是死锁)而失败。

不幸的是,我不知道我的onDraw(...)onTouchEvent(...) 实现有什么问题或如何改进代码。你能帮忙吗?

"main" prio=5 tid=1 MONITOR
| group="main" sCount=1 dsCount=0 obj=0x41920e88 self=0x4190f8d0
| sysTid=13407 nice=0 sched=0/0 cgrp=apps handle=1074618708
| state=S schedstat=( 50780242971 27570770290 130442 ) utm=4254 stm=824 core=0
at com.my.package.util.HandCards.onTouchEvent(SourceFile:~188)
- waiting to lock <0x45b91988> (a android.view.SurfaceView$4) held by tid=18 (Thread-14297)
at android.view.View.dispatchTouchEvent(View.java:7837)
at android.view.ViewGroup.dispatchTransformedTouchEvent(ViewGroup.java:2216)
at android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:1917)
at android.view.ViewGroup.dispatchTransformedTouchEvent(ViewGroup.java:2216)
at android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:1917)
at android.view.ViewGroup.dispatchTransformedTouchEvent(ViewGroup.java:2216)
at android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:1917)
at android.view.ViewGroup.dispatchTransformedTouchEvent(ViewGroup.java:2216)
at android.view.ViewGroup.dispatchTouchEvent(ViewGroup.java:1917)
at com.android.internal.policy.impl.PhoneWindow$DecorView.superDispatchTouchEvent(PhoneWindow.java:2075)
at com.android.internal.policy.impl.PhoneWindow.superDispatchTouchEvent(PhoneWindow.java:1522)
at android.app.Activity.dispatchTouchEvent(Activity.java:2458)
at com.android.internal.policy.impl.PhoneWindow$DecorView.dispatchTouchEvent(PhoneWindow.java:2023)
at android.view.View.dispatchPointerEvent(View.java:8017)
at android.view.ViewRootImpl$ViewPostImeInputStage.processPointerEvent(ViewRootImpl.java:3966)
at android.view.ViewRootImpl$ViewPostImeInputStage.onProcess(ViewRootImpl.java:3845)
at android.view.ViewRootImpl$InputStage.deliver(ViewRootImpl.java:3405)
at android.view.ViewRootImpl$InputStage.onDeliverToNext(ViewRootImpl.java:3455)
at android.view.ViewRootImpl$InputStage.forward(ViewRootImpl.java:3424)
at android.view.ViewRootImpl$AsyncInputStage.forward(ViewRootImpl.java:3531)
at android.view.ViewRootImpl$InputStage.apply(ViewRootImpl.java:3432)
at android.view.ViewRootImpl$AsyncInputStage.apply(ViewRootImpl.java:3588)
at android.view.ViewRootImpl$InputStage.deliver(ViewRootImpl.java:3405)
at android.view.ViewRootImpl$InputStage.onDeliverToNext(ViewRootImpl.java:3455)
at android.view.ViewRootImpl$InputStage.forward(ViewRootImpl.java:3424)
at android.view.ViewRootImpl$InputStage.apply(ViewRootImpl.java:3432)
at android.view.ViewRootImpl$InputStage.deliver(ViewRootImpl.java:3405)
at android.view.ViewRootImpl.deliverInputEvent(ViewRootImpl.java:5554)
at android.view.ViewRootImpl.doProcessInputEvents(ViewRootImpl.java:5534)
at android.view.ViewRootImpl.enqueueInputEvent(ViewRootImpl.java:5505)
at android.view.ViewRootImpl$WindowInputEventReceiver.onInputEvent(ViewRootImpl.java:5634)
at android.view.InputEventReceiver.dispatchInputEvent(InputEventReceiver.java:185)
at android.os.MessageQueue.nativePollOnce(Native Method)
at android.os.MessageQueue.next(MessageQueue.java:138)
at android.os.Looper.loop(Looper.java:196)
at android.app.ActivityThread.main(ActivityThread.java:5135)
at java.lang.reflect.Method.invokeNative(Native Method)
at java.lang.reflect.Method.invoke(Method.java:515)
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:878)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:694)
at dalvik.system.NativeStart.main(Native Method)

...

"Thread-14297" prio=5 tid=18 SUSPENDED
| group="main" sCount=1 dsCount=0 obj=0x45ba6358 self=0x76036b38
| sysTid=21120 nice=0 sched=0/0 cgrp=apps handle=1979936656
| state=S schedstat=( 48296386737 3088012659 22649 ) utm=4691 stm=138 core=0
#00 pc 00021adc /system/lib/libc.so (__futex_syscall3+8)
#01 pc 0000f074 /system/lib/libc.so (__pthread_cond_timedwait_relative+48)
#02 pc 0000f0d4 /system/lib/libc.so (__pthread_cond_timedwait+64)
#03 pc 0005655f /system/lib/libdvm.so
#04 pc 00056b21 /system/lib/libdvm.so (dvmChangeStatus(Thread*, ThreadStatus)+34)
#05 pc 00050fd7 /system/lib/libdvm.so (dvmCallJNIMethod(unsigned int const*, JValue*, Method const*, Thread*)+406)
#06 pc 00000214 /dev/ashmem/dalvik-jit-code-cache (deleted)
at android.graphics.Canvas.native_drawBitmap(Native Method)
at android.graphics.Canvas.drawBitmap(Canvas.java:1202)
at com.my.package.util.HandCards.a(SourceFile:178)
at com.my.package.util.HandCards.onDraw(SourceFile:136)
at com.my.package.util.d.run(SourceFile:36)

HandCards.onTouchEvent(SourceFile:~188) 是:

synchronized (mRenderThread.getSurfaceHolder()) {

HandCards.a(SourceFile:178) 是:

canvas.drawBitmap(drawCardBitmap, null, mDrawingRect, mGraphicsPaint);

SurfaceView 子类的完整代码是:

public class HandCards extends SurfaceView implements SurfaceHolder.Callback {

    /** Opacity of the shadow layer that hides other cards when one card is highlighted and covers all cards when it's another player's turn (where 0 is transparent and 255 is opaque) */
    private static final int SHADOW_ALPHA = 150;
    private static SparseArray<Bitmap> mCardCache = new SparseArray<Bitmap>(); // cache array for own card bitmaps
    private HandThread mRenderThread;
    private volatile List<Card> mCards;
    private volatile int mCardCount;
    private volatile int mScreenWidth;
    private volatile int mScreenHeight;
    private volatile int mCardWidth;
    private volatile int mCardHeight;
    private volatile int mHighlightedCard = -1;
    private CardClickCallback mCardClickCallback;
    private volatile int mBlattID = 1;
    private volatile int mCurrentCardSpacing;
    private final Paint mGraphicsPaint;
    private final Paint mShadowPaint;
    private final Rect mDrawingRect;
    private volatile int mTouchEventAction;
    private volatile int mTouchEventCard;
    private Bitmap drawCardBitmap;
    private volatile int mOnDrawX1;
    private final BitmapFactory.Options mBitmapOptions;
    private volatile boolean mIsActive = true;
    private final int[] mCardSelection = new int[GameState.MAX_SWAP_CARDS];
    /** Indicates that the card view is currently used for choosing some cards to create a selection */
    private volatile boolean mIsChooseMode;
    /** Holds the index of the selected card that will be replaced next if all selection slots are full */
    private volatile int mNextReplacePosition;
    /** Used only locally in drawCard() but is declared here to save repeated allocations */
    private volatile int mCardOffsetY;
    private volatile int mRequiredSelectionCount;

    public HandCards(Context activityContext, AttributeSet attributeSet) {
        super(activityContext, attributeSet);
        getHolder().addCallback(this);
        setFocusable(true); // touch events should be processed by this class
        mCards = new ArrayList<Card>();
        mGraphicsPaint = new Paint();
        mGraphicsPaint.setAntiAlias(true);
        mGraphicsPaint.setFilterBitmap(true);
        mShadowPaint = new Paint();
        mShadowPaint.setARGB(SHADOW_ALPHA, 20, 20, 20);
        mShadowPaint.setAntiAlias(true);
        mBitmapOptions = new BitmapFactory.Options();
        mBitmapOptions.inInputShareable = true;
        mBitmapOptions.inPurgeable = true;
        mDrawingRect = new Rect();
    }

    public Card getCard(int location) throws Exception {
        if (mCards != null) {
            synchronized (mCards) {
                return mCards.get(location); // card may not be found (throw exception then)
            }
        }
        return null;
    }

    public static Bitmap cardCacheGet(int key) {
        synchronized (mCardCache) {
            return mCardCache.get(key);
        }
    }

    public static void cardCachePut(int key, Bitmap object) {
        synchronized (mCardCache) {
            mCardCache.put(key, object);
        }
    }

    public int[] getSelectedCards() {
        return mCardSelection;
    }

    public void setActive(boolean active) {
        if (mCardSelection != null) {
            for (int i = 0; i < GameState.MAX_SWAP_CARDS; i++) { // loop through all slots for selected cards
                mCardSelection[i] = -1; // unset the slot so that it is empty by default
            }
        }
        mIsActive = active;
    }

    public boolean isActive() {
        return mIsActive;
    }

    public void setChooseMode(boolean active, int swapCardCount) {
        mNextReplacePosition = 0;
        mIsChooseMode = active;
        mRequiredSelectionCount = swapCardCount;
    }

    public boolean isChooseMode() {
        return mIsChooseMode;
    }

    public void stopThread() {
        if (mRenderThread != null) {
            mRenderThread.setRunning(false);
        }
    }

    @Override
    public void onDraw(Canvas canvas) {
        if (canvas != null) {
            synchronized (mCards) {
                mCardCount = mCards.size();
                canvas.drawColor(Color.BLACK);
                if (mCardCount > 0) {
                    mCurrentCardSpacing = Math.min(mScreenWidth/mCardCount, mCardWidth);
                    for (int c = 0; c < mCardCount; c++) {
                        if (c != mHighlightedCard || !isActive()) {
                            try {
                                drawCard(canvas, mCards.get(c).getDrawableID(mBlattID), false, c*mCurrentCardSpacing, c*mCurrentCardSpacing+mCardWidth, c);
                            }
                            catch (Exception e) { }
                        }
                    }
                    if (mHighlightedCard > -1 && isActive()) {
                        mOnDrawX1 = Math.min(mHighlightedCard*mCurrentCardSpacing, mScreenWidth-mCardWidth);
                        try {
                            drawCard(canvas, mCards.get(mHighlightedCard).getDrawableID(mBlattID), true, mOnDrawX1, mOnDrawX1+mCardWidth, mHighlightedCard);
                        }
                        catch (Exception e) { }
                    }
                    else if (!isActive()) {
                        drawCard(canvas, 0, true, 0, mScreenWidth, 0);
                    }
                }
            }
        }
    }

    private void drawCard(Canvas canvas, int resourceID, boolean highlighted, int xLeft, int xRight, int cardPosition) {
        if (canvas != null) {
            try {
                if (highlighted) {
                    canvas.drawRect(0, 0, mScreenWidth, mScreenHeight, mShadowPaint);
                }
                if (resourceID != 0) {
                    drawCardBitmap = cardCacheGet(resourceID);
                    if (drawCardBitmap == null) {
                        drawCardBitmap = BitmapFactory.decodeResource(getResources(), resourceID, mBitmapOptions);
                        cardCachePut(resourceID, drawCardBitmap);
                    }
                    mCardOffsetY = 0; // by default draw all cards right at the bottom (without highlighting by position)
                    if (mCardSelection != null) {
                        for (int i = 0; i < GameState.MAX_SWAP_CARDS; i++) { // loop through all slots for selected cards
                            if (mCardSelection[i] == cardPosition) { // if current card has been selected (in that slot)
                                mCardOffsetY = mScreenHeight*1/4; // lift the card by one quarter to highlight it
                                break; // card has already been detected to be selected so stop here
                            }
                        }
                    }
                    mDrawingRect.set(xLeft, mCardOffsetY, xRight, mCardHeight+mCardOffsetY);
                    canvas.drawBitmap(drawCardBitmap, null, mDrawingRect, mGraphicsPaint);
                }
            }
            catch (Exception e) { }
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (mRenderThread == null) { return false; }
        synchronized (mRenderThread.getSurfaceHolder()) { // synchronized so that there are no concurrent accesses
            mTouchEventAction = event.getAction();
            if (isActive()) {
                if (mTouchEventAction == MotionEvent.ACTION_DOWN || mTouchEventAction == MotionEvent.ACTION_MOVE) {
                    if (event.getY() >= 0 && event.getY() < mScreenHeight) {
                        mTouchEventCard = (int) event.getX()/mCurrentCardSpacing;
                        if (mTouchEventCard > -1 && mTouchEventCard < mCardCount) {
                            mHighlightedCard = mTouchEventCard;
                        }
                        else {
                            mHighlightedCard = -1;
                        }
                    }
                    else {
                        mHighlightedCard = -1;
                    }
                }
                else if (mTouchEventAction == MotionEvent.ACTION_UP) {
                    if (mCardClickCallback != null && mHighlightedCard > -1 && mHighlightedCard < mCardCount) {
                        if (isChooseMode()) { // card has been chosen as a swap card
                            int freeSelectionIndex = -1; // remember the index of a free selection slot (default = none available)
                            for (int i = 0; i < mRequiredSelectionCount; i++) { // loop through all allowed slots for selected cards
                                if (mCardSelection[i] == mHighlightedCard) { // if this card has already been selected
                                    mCardSelection[i] = -1; // unselect the card
                                    freeSelectionIndex = -2; // mark that there is no need to select a new card
                                    break; // slot of current card has already been found so stop here
                                }
                                else if (mCardSelection[i] == -1 && freeSelectionIndex == -1) { // if slot is still available and no free slot has been found yet
                                    freeSelectionIndex = i; // remember the index of this free slot
                                }
                            }
                            if (freeSelectionIndex > -2) { // if a new card is to be placed in the selection array
                                if (freeSelectionIndex >= 0) { // if a free slot was available
                                    mCardSelection[freeSelectionIndex] = mHighlightedCard; // just place the card there
                                }
                                else { // if no free slot was available anymore
                                    mCardSelection[mNextReplacePosition] = mHighlightedCard; // replace another card in one of the slots
                                    mNextReplacePosition = (mNextReplacePosition+1) % mRequiredSelectionCount; // advance the cursor that points to the slot which will be replaced next
                                }
                            }
                        }
                        else { // card has been selected to be played on the table
                            try {
                                mCardClickCallback.chooseCard(mCards.get(mHighlightedCard));
                            }
                            catch (Exception e) {
                                // index was out of mCards' bounds (just ignore this, user may tap on card again)
                            }
                        }
                    }
                    mHighlightedCard = -1;
                }
            }
            else {
                try {
                    mCardClickCallback.resyncManually();
                }
                catch (Exception e) { }
            }
        }
        return true;
    }

    @Override
    public void surfaceChanged(SurfaceHolder arg0, int arg1, int arg2, int arg3) { }

    public void setCards(List<Card> currentCards) {
        synchronized (mCards) {
            mCards.clear();
            mCards.addAll(currentCards);
        }
    }

    @Override
    public void surfaceCreated(SurfaceHolder arg0) {
        mScreenWidth = getWidth();
        mScreenHeight = getHeight();
        mCardHeight = mScreenHeight;
        mCardWidth = mCardHeight*99/150;
        mCurrentCardSpacing = mCardWidth;
        mRenderThread = new HandThread(getHolder(), this);
        mRenderThread.setRunning(true);
        mRenderThread.start();
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
        boolean retry = true;
        mRenderThread.setRunning(false); // stop thread
        while (retry) { // wait for thread to close
            try {
                mRenderThread.join();
                retry = false;
            }
            catch (InterruptedException e) { }
        }
    }

    public synchronized void setCardClickCallback(CardClickCallback callback) {
        mCardClickCallback = callback;
    }

    public void setBlattID(int blattID) {
        mBlattID = blattID;
    }

}

然后,还有渲染线程:

public class HandThread extends Thread {

    private final SurfaceHolder mSurfaceHolder;
    private final HandCards mSurface;
    private volatile boolean mRunning = false;

    public HandThread(SurfaceHolder surfaceHolder, HandCards surface) {
        mSurfaceHolder = surfaceHolder;
        mSurface = surface;
    }

    public SurfaceHolder getSurfaceHolder() {
        return mSurfaceHolder;
    }

    public void setRunning(boolean run) {
        mRunning = run;
    }

    @Override
    public void run() {
        Canvas c;
        while (mRunning) {
            c = null;
            try {
                c = mSurfaceHolder.lockCanvas(null);
                synchronized (mSurfaceHolder) {
                    if (c != null) {
                        mSurface.onDraw(c);
                    }
                }
            }
            finally { // when exception is thrown above we may not leave the surface in an inconsistent state
                if (c != null) {
                    try {
                        mSurfaceHolder.unlockCanvasAndPost(c);
                    }
                    catch (Exception e) { }
                }
            }
        }
    }

}

最佳答案

发生 ANR 是因为您的 onTouchEvent() 方法正在同步 tid=18 持有的锁,这是一个未命名的线程,仅称为 Thread-14297。

许多人遵循一种模式,即在锁定 SurfaceView Canvas 时,他们也会锁定 SurfaceHolder 对象。在具有公共(public)可见性的对象上同步是一个坏主意,在与 GUI 框架共享的对象上同步更糟糕,所以很遗憾这种模式仍然存在。 (但我离题了。)

您正在重写的 onDraw() 方法中绘制,如果您是从渲染器线程绘制,则该方法没有任何意义 -- onDraw()方法由 View 层次结构使用,并且会从 UI 线程调用,但这里显然是从其他地方调用的。您应该将其命名为其他名称,也许只是 myDraw()。 (但我离题了。)

Thread-14297 处于“暂停”状态,这意味着它正在执行但在捕获堆栈跟踪时停止了。由于最顶层的框架是一个本地方法——它不会被 VM 挂起——它可能正在进入或退出框架。线程的系统和用户时间(以刻度显示为“utm=”和“stm=”值)相当低,表明它没有执行过多的 CPU 工作。当然,除非您的渲染线程是一次性的,在这种情况下它相当繁忙(并且可能尚未完成)。

好消息是您似乎没有陷入僵局。渲染线程运行缓慢。或者,也许您有一个无法退出的循环(尽管从发布的代码中看不出任何一个)。在速度较慢的设备上,系统上有很多其他 Activity ,并且 mCards 列表很大,它可能会因 CPU 不足而无法快速响应。假设您遵循通用模式并在抓取 Canvas 时锁定 SurfaceHolder,则您的 onTouchEvent() 将在整个绘制过程中锁定 UI 线程。 logcat 中的 ANR 摘要通常会列出最近的线程 Activity 级别;如果您有权访问它,该信息可以告诉您渲染线程有多忙。

并非所有 ANR 都是致命的。如果应用程序变得永久无响应,这与用户点击“等待”时清除的临时 ANR 有很大不同。你知道这是哪一种吗?

你需要:

  1. 重新评估您的数据同步。使用较短的窗口和读写锁来传递数据。在 java.util.concurrent 中查找。长时间停止 UI 线程是不好的。
  2. 确定为什么您的渲染似乎花费了很长时间,以及它是运行缓慢还是永远旋转。

关于android - Android 上 SurfaceView 方法 "onTouchEvent(...)"中的 ANR,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/28822666/

有关android - Android 上 SurfaceView 方法 "onTouchEvent(...)"中的 ANR的更多相关文章

  1. ruby - 如何使用 Nokogiri 的 xpath 和 at_xpath 方法 - 2

    我正在学习如何使用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

  2. ruby - 如何从 ruby​​ 中的字符串运行任意对象方法? - 2

    总的来说,我对ruby​​还比较陌生,我正在为我正在创建的对象编写一些rspec测试用例。许多测试用例都非常基础,我只是想确保正确填充和返回值。我想知道是否有办法使用循环结构来执行此操作。不必为我要测试的每个方法都设置一个assertEquals。例如:describeitem,"TestingtheItem"doit"willhaveanullvaluetostart"doitem=Item.new#HereIcoulddotheitem.name.shouldbe_nil#thenIcoulddoitem.category.shouldbe_nilendend但我想要一些方法来使用

  3. ruby - 为什么我可以在 Ruby 中使用 Object#send 访问私有(private)/ protected 方法? - 2

    类classAprivatedeffooputs:fooendpublicdefbarputs:barendprivatedefzimputs:zimendprotecteddefdibputs:dibendendA的实例a=A.new测试a.foorescueputs:faila.barrescueputs:faila.zimrescueputs:faila.dibrescueputs:faila.gazrescueputs:fail测试输出failbarfailfailfail.发送测试[:foo,:bar,:zim,:dib,:gaz].each{|m|a.send(m)resc

  4. ruby - 其他文件中的 Rake 任务 - 2

    我试图在一个项目中使用rake,如果我把所有东西都放到Rakefile中,它会很大并且很难读取/找到东西,所以我试着将每个命名空间放在lib/rake中它自己的文件中,我添加了这个到我的rake文件的顶部:Dir['#{File.dirname(__FILE__)}/lib/rake/*.rake'].map{|f|requiref}它加载文件没问题,但没有任务。我现在只有一个.rake文件作为测试,名为“servers.rake”,它看起来像这样:namespace:serverdotask:testdoputs"test"endend所以当我运行rakeserver:testid时

  5. ruby-on-rails - Ruby net/ldap 模块中的内存泄漏 - 2

    作为我的Rails应用程序的一部分,我编写了一个小导入程序,它从我们的LDAP系统中吸取数据并将其塞入一个用户表中。不幸的是,与LDAP相关的代码在遍历我们的32K用户时泄漏了大量内存,我一直无法弄清楚如何解决这个问题。这个问题似乎在某种程度上与LDAP库有关,因为当我删除对LDAP内容的调用时,内存使用情况会很好地稳定下来。此外,不断增加的对象是Net::BER::BerIdentifiedString和Net::BER::BerIdentifiedArray,它们都是LDAP库的一部分。当我运行导入时,内存使用量最终达到超过1GB的峰值。如果问题存在,我需要找到一些方法来更正我的代

  6. ruby - Facter::Util::Uptime:Module 的未定义方法 get_uptime (NoMethodError) - 2

    我正在尝试设置一个puppet节点,但ruby​​gems似乎不正常。如果我通过它自己的二进制文件(/usr/lib/ruby/gems/1.8/gems/facter-1.5.8/bin/facter)在cli上运行facter,它工作正常,但如果我通过由ruby​​gems(/usr/bin/facter)安装的二进制文件,它抛出:/usr/lib/ruby/1.8/facter/uptime.rb:11:undefinedmethod`get_uptime'forFacter::Util::Uptime:Module(NoMethodError)from/usr/lib/ruby

  7. ruby-on-rails - rails : "missing partial" when calling 'render' in RSpec test - 2

    我正在尝试测试是否存在表单。我是Rails新手。我的new.html.erb_spec.rb文件的内容是:require'spec_helper'describe"messages/new.html.erb"doit"shouldrendertheform"dorender'/messages/new.html.erb'reponse.shouldhave_form_putting_to(@message)with_submit_buttonendendView本身,new.html.erb,有代码:当我运行rspec时,它失败了:1)messages/new.html.erbshou

  8. ruby-on-rails - 由于 "wkhtmltopdf",PDFKIT 显然无法正常工作 - 2

    我在从html页面生成PDF时遇到问题。我正在使用PDFkit。在安装它的过程中,我注意到我需要wkhtmltopdf。所以我也安装了它。我做了PDFkit的文档所说的一切......现在我在尝试加载PDF时遇到了这个错误。这里是错误:commandfailed:"/usr/local/bin/wkhtmltopdf""--margin-right""0.75in""--page-size""Letter""--margin-top""0.75in""--margin-bottom""0.75in""--encoding""UTF-8""--margin-left""0.75in""-

  9. ruby-on-rails - Rails 3 中的多个路由文件 - 2

    Rails2.3可以选择随时使用RouteSet#add_configuration_file添加更多路由。是否可以在Rails3项目中做同样的事情? 最佳答案 在config/application.rb中:config.paths.config.routes在Rails3.2(也可能是Rails3.1)中,使用:config.paths["config/routes"] 关于ruby-on-rails-Rails3中的多个路由文件,我们在StackOverflow上找到一个类似的问题

  10. Ruby 方法() 方法 - 2

    我想了解Ruby方法methods()是如何工作的。我尝试使用“ruby方法”在Google上搜索,但这不是我需要的。我也看过ruby​​-doc.org,但我没有找到这种方法。你能详细解释一下它是如何工作的或者给我一个链接吗?更新我用methods()方法做了实验,得到了这样的结果:'labrat'代码classFirstdeffirst_instance_mymethodenddefself.first_class_mymethodendendclassSecond使用类#returnsavailablemethodslistforclassandancestorsputsSeco

随机推荐